Spaces:
Sleeping
Sleeping
Add Stripe connector: POST /v1/connect/stripe β paste read-only key, auto-score customers from billing data
687722a verified | import pandas as pd | |
| import numpy as np | |
| from typing import List, Dict, Any, Tuple, Optional | |
| def _safe_float(val: Any, default: float = 0.0) -> float: | |
| try: | |
| return float(val) if val is not None else default | |
| except (ValueError, TypeError): | |
| return default | |
| def _safe_int(val: Any, default: int = 0) -> int: | |
| try: | |
| return int(float(val)) if val is not None else default | |
| except (ValueError, TypeError): | |
| return default | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # CHURN HEURISTIC RULES | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _churn_factor(row: Dict[str, Any]) -> tuple: | |
| rules = [] | |
| days_off = _safe_float(row.get("days_since_last_login"), 0) | |
| if days_off >= 14: | |
| rules.append(("Login Recency", 25, f"No login in {int(days_off)} days")) | |
| elif days_off >= 7: | |
| rules.append(("Login Recency", 15, f"No login in {int(days_off)} days")) | |
| logins = _safe_float(row.get("login_frequency_7d"), 10) | |
| if logins <= 1: | |
| rules.append(("Low Engagement", 20, f"Only {int(logins)} logins this week")) | |
| elif logins <= 3: | |
| rules.append(("Low Engagement", 10, f"Only {int(logins)} logins this week")) | |
| tickets = _safe_float(row.get("support_tickets_last_30d"), 0) | |
| if tickets >= 5: | |
| rules.append(("Support Friction", 15, f"{int(tickets)} tickets in 30 days")) | |
| elif tickets >= 3: | |
| rules.append(("Support Friction", 8, f"{int(tickets)} tickets in 30 days")) | |
| delays = _safe_float(row.get("payment_delays_90d"), 0) | |
| if delays >= 3: | |
| rules.append(("Payment Failure", 25, f"{int(delays)} payment delays")) | |
| elif delays >= 1: | |
| rules.append(("Payment Failure", 12, f"{int(delays)} payment delays in 90 days")) | |
| adoption = _safe_float(row.get("feature_adoption_score"), 100) | |
| if adoption <= 30: | |
| rules.append(("Low Adoption", 10, f"Only {adoption:.0f}% feature adoption")) | |
| nps = _safe_float(row.get("nps_score"), 10) | |
| if nps <= 4: | |
| rules.append(("Low NPS", 10, f"NPS score of {int(nps)}/10")) | |
| tenure = _safe_float(row.get("tenure_days"), 365) | |
| if tenure <= 60: | |
| rules.append(("Short Tenure", 10, f"Only {int(tenure)} days as customer")) | |
| elif tenure <= 90: | |
| rules.append(("Short Tenure", 5, f"Only {int(tenure)} days as customer")) | |
| ct = str(row.get("contract_type", "")).strip().lower() | |
| if ct in ("month-to-month", "month to month", "monthly"): | |
| rules.append(("Contract Risk", 10, "Month-to-month contract")) | |
| # Billing status β strongest signal when sourced from Stripe | |
| sub_status = str(row.get("subscription_status", "")).strip().lower() | |
| if sub_status in ("canceled", "cancelled"): | |
| rules.append(("Subscription Canceled", 40, "Stripe subscription canceled")) | |
| elif sub_status in ("past_due", "unpaid"): | |
| rules.append(("Billing Past Due", 25, f"Stripe status: {sub_status}")) | |
| elif sub_status == "incomplete_expired": | |
| rules.append(("Failed Signup", 20, "Subscription never activated")) | |
| session = _safe_float(row.get("avg_session_minutes"), 60) | |
| if session <= 5: | |
| rules.append(("Low Sessions", 5, f"Avg session {session:.1f} min")) | |
| call_score = _safe_float(row.get("call_sentiment_churn_risk"), None) | |
| if call_score is not None and call_score >= 70: | |
| rules.append(("Cancellation Intent (Audio)", 30, f"Call churn score: {int(call_score)}%")) | |
| elif call_score is not None and call_score >= 40: | |
| rules.append(("Call Concern (Audio)", 15, f"Call churn score: {int(call_score)}%")) | |
| call_sentiment = _safe_float(row.get("call_sentiment"), None) | |
| if call_sentiment is not None and call_sentiment < -0.5: | |
| rules.append(("Negative Sentiment (Audio)", 15, f"Sentiment: {call_sentiment:.2f}")) | |
| keyword_count = _safe_int(row.get("flagged_keyword_count"), None) | |
| if keyword_count is not None and keyword_count >= 2: | |
| rules.append(("Churn Keywords (Audio)", 10, f"{int(keyword_count)} flagged keywords")) | |
| score = min(sum(r[1] for r in rules), 100) | |
| factors = [{"rule": r[0], "points": r[1], "detail": r[2]} for r in rules] | |
| return score, factors | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # LEAD HEURISTIC RULES | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _lead_factor(row: Dict[str, Any]) -> tuple: | |
| rules = [] | |
| demo = _safe_int(row.get("demo_requested"), 0) | |
| if demo == 1: | |
| rules.append(("Demo Requested", 25, "Demo has been requested")) | |
| budget = _safe_int(row.get("budget_confirmed"), 0) | |
| if budget == 1: | |
| rules.append(("Budget Confirmed", 20, "Budget is confirmed")) | |
| dm = _safe_int(row.get("decision_maker_contacted"), 0) | |
| if dm == 1: | |
| rules.append(("DM Access", 20, "Decision maker contacted")) | |
| eng = _safe_float(row.get("engagement_score"), 0) | |
| if eng >= 60: | |
| rules.append(("High Engagement", 15, f"Engagement score {eng:.0f}/100")) | |
| src = str(row.get("source", "")).strip().lower() | |
| if src in ("referral", "organic", "paid ads"): | |
| rules.append(("Quality Source", 10, f"Source: {src.title()}")) | |
| dip = _safe_float(row.get("days_in_pipeline"), 60) | |
| if dip <= 14: | |
| rules.append(("Fresh Lead", 10, f"Only {int(dip)} days in pipeline")) | |
| convs = _safe_float(row.get("previous_conversations"), 0) | |
| if convs >= 3: | |
| rules.append(("Active Relationship", 10, f"{int(convs)} conversations")) | |
| downloads = _safe_float(row.get("content_downloads"), 0) | |
| if downloads >= 3: | |
| rules.append(("Content Interest", 5, f"{int(downloads)} downloads")) | |
| opens = _safe_float(row.get("email_opens"), 0) | |
| if opens >= 5: | |
| rules.append(("Email Engagement", 5, f"{int(opens)} email opens")) | |
| visitors = _safe_float(row.get("website_visits"), 0) | |
| if visitors >= 5: | |
| rules.append(("Website Activity", 5, f"{int(visitors)} visits")) | |
| score = min(sum(r[1] for r in rules), 100) | |
| factors = [{"rule": r[0], "points": r[1], "detail": r[2]} for r in rules] | |
| return score, factors | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # CHURN KEYWORDS FOR AUDIO ANALYSIS | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| CHURN_KEYWORDS = [ | |
| ("cancel", 30), | |
| ("cancel my account", 30), | |
| ("not renewing", 30), | |
| ("refund", 25), | |
| ("chargeback", 25), | |
| ("too expensive", 20), | |
| ("cheaper", 20), | |
| ("overpriced", 20), | |
| ("can't afford", 20), | |
| ("competitor", 15), | |
| ("switching to", 15), | |
| ("better option", 15), | |
| ("not working", 20), | |
| ("broken", 20), | |
| ("bug", 15), | |
| ("glitch", 15), | |
| ("unusable", 20), | |
| ("frustrated", 15), | |
| ("fed up", 20), | |
| ("done with this", 25), | |
| ("never works", 20), | |
| ("waste of money", 25), | |
| ("leaving", 15), | |
| ("close account", 25), | |
| ("unsubscribe", 15), | |
| ("stop service", 20), | |
| ("downgrade", 10), | |
| ("not happy", 15), | |
| ("disappointed", 15), | |
| ] | |
| def detect_churn_keywords(transcript: str) -> dict: | |
| transcript_lower = transcript.lower() | |
| flagged = [] | |
| score = 0 | |
| for keyword, points in CHURN_KEYWORDS: | |
| if keyword in transcript_lower: | |
| flagged.append(keyword) | |
| score = max(score, points) | |
| # Count total flagged keywords for aggregate signal | |
| return { | |
| "flagged_keywords": flagged, | |
| "churn_intent_score": min(score, 100), | |
| "flagged_keyword_count": len(flagged), | |
| } | |