← back to Rentv Sheet Enrich Refine

warmth.py

127 lines

"""
Warmth scoring for RENTV leads — one definition of "warm", shared by the live
console (live_server.py) and the top-N export (export_top.py) so they never drift.

A lead's warmth blends the three signals that already live in the sheet:
  1. Centrality  — # Appearances (how many source tabs a contact shows up on).
  2. Intent      — Status (color): a flagged/left-message lead outranks a cold one.
  3. Reachability— do we actually have an email / phone / LinkedIn / enriched VP?
Plus a Strong-Prospects membership bonus (Steve's own hand-tagged short list).

The score is a plain int. Higher = work first. Section/header rows get a large
negative so they sink to the bottom under a descending sort.

Pure/stdlib, no deps — same input always yields the same score ($0, offline).
"""

WARMTH_HEADER = "🔥 Warmth"

# Status (color) -> intent weight. Keys match the sheet's inferred status labels.
_STATUS_WEIGHT = {
    "Flag / Priority": 40,
    "In Progress / Left Message": 32,
    "Prospect / Follow-up": 26,
    "Active / Contacted": 20,
    "Needs Email / Outreach Pending": 12,
    "On Hold / Inactive": -25,
    "Section / Category": -100,   # header/divider rows — sink them
}


def _cell(row, idx, name):
    i = idx.get(name)
    if i is None or i >= len(row):
        return ""
    v = row[i]
    return "" if v is None else str(v).strip()


def _int(s):
    try:
        return int(float(str(s).strip() or 0))
    except (ValueError, TypeError):
        return 0


def score(row, idx):
    """row = list of cell values; idx = {header_name: col_index}. Returns int warmth."""
    pts = 0

    # 1. Centrality — capped so a contact spammed across every tab can't dominate.
    appearances = _int(_cell(row, idx, "# Appearances"))
    pts += min(appearances, 6) * 8            # 0..48

    # 2. Intent
    status = _cell(row, idx, "Status (color)")
    pts += _STATUS_WEIGHT.get(status, 0)

    # 3. Strong-Prospects membership (Steve's hand-tagged list)
    src = _cell(row, idx, "Source Tabs").lower()
    if "strong prospects" in src:
        pts += 30

    # 4. Reachability — a warm lead we can't contact is worth less.
    email = _cell(row, idx, "Email") or _cell(row, idx, "Updated Email (found)") \
        or _cell(row, idx, "Likely Email (inferred)")
    if email:
        pts += 10
    phone = any(_cell(row, idx, p) for p in
                ("Phone 1", "Phone 2", "Phone 3", "Company Phone (found)", "Marketing/VP Phone"))
    if phone:
        pts += 6
    if _cell(row, idx, "Marketing/VP Name") or _cell(row, idx, "Marketing/VP Contact"):
        pts += 8
    if _cell(row, idx, "LinkedIn (Contact)") or _cell(row, idx, "LinkedIn (Company)"):
        pts += 3

    return pts


def reason(row, idx):
    """Short human 'why warm' string for the export — mirrors score()'s inputs."""
    bits = []
    app = _int(_cell(row, idx, "# Appearances"))
    if app:
        bits.append(f"{app} tab{'s' if app != 1 else ''}")
    status = _cell(row, idx, "Status (color)")
    if status and status != "Section / Category":
        bits.append(status)
    if "strong prospects" in _cell(row, idx, "Source Tabs").lower():
        bits.append("Strong Prospect")
    reach = []
    if _cell(row, idx, "Email") or _cell(row, idx, "Updated Email (found)") \
            or _cell(row, idx, "Likely Email (inferred)"):
        reach.append("email")
    if any(_cell(row, idx, p) for p in
           ("Phone 1", "Phone 2", "Phone 3", "Company Phone (found)", "Marketing/VP Phone")):
        reach.append("phone")
    if _cell(row, idx, "Marketing/VP Name"):
        reach.append("VP contact")
    if reach:
        bits.append("has " + "/".join(reach))
    return "; ".join(bits)


def can_score(headers):
    """Only augment tabs that actually carry the signals — others get no column."""
    hs = set(headers)
    return ("Status (color)" in hs) or ("# Appearances" in hs)


def augment(payload):
    """Given a tab/combined payload {headers, rows, ...}, append the Warmth column
    (in the RESPONSE only — never written back to the sheet). Returns the payload."""
    headers = payload.get("headers", [])
    if not can_score(headers):
        return payload
    idx = {h: i for i, h in enumerate(headers)}
    new_headers = list(headers) + [WARMTH_HEADER]
    new_rows = []
    for r in payload.get("rows", []):
        new_rows.append(list(r) + [score(r, idx)])
    payload = dict(payload)
    payload["headers"] = new_headers
    payload["rows"] = new_rows
    payload["warmth_col"] = len(new_headers) - 1   # so the UI can find it
    return payload