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), }