route.py
Language Python9.3 KB186 lines
"""route: the weekly outreach candidate lists."""from __future__ import annotationsimport csvimport datetime as dtimport jsonimport reimport sysfrom .census import norm_keyfrom .classify import classify_onefrom .config import NON_PARTNER, is_armed, load_configfrom .history import apply_merges, compute_metrics, load_history, load_merge_registryfrom .paths import CLIENTS_DIR, OUT_DIR, SUPPRESSION, TODAY, XML_MAPfrom .stages import OPTOUT_STAGESROLE_NAME_RE = re.compile(r"\b(test|testing|marketing|vendors?|department|dept|purchasing|expense|admin|office|"r"accounts?|accounting|info|sales|creatives?|design|graphics|print|shop|store|team|"r"staff|mngt|management|hr|billing)\b", re.I)def cmd_route(args):"""Evaluate pipeline-routing-rules.md against the census. Read-only on the vault;writes candidate artifacts to 05-Outputs/partner-runs/. Never touches Bitrix.Rules: Winback first (partner, no order 12+ months, one touch per dormancyspell); cat 24 = active never-contacted + tier crossings; Dormant queue= active, previously contacted, no open deal, longest-since-outreach first. Opt-outs(suppression file + any Not-Interested closure) never route anywhere."""import xml.etree.ElementTree as ETcfg = load_config()armed, reasons = is_armed(cfg)if not armed:sys.exit("REFUSED: thresholds not set (" + "; ".join(reasons) + ")")h = load_history()metrics = compute_metrics(h, cfg)aliases, _ = load_merge_registry()metrics, _ = apply_merges(metrics, aliases)supp_names, supp_emails, supp_domains = set(), set(), set()if SUPPRESSION.exists():try:s = json.loads(SUPPRESSION.read_text(encoding="utf-8"))supp_names = {norm_key(x) for x in s.get("names", [])}supp_emails = {x.strip().lower() for x in s.get("emails", [])}# Whole-domain never-contact list. Screening role-shaped names cannot see# a domain: a directly partnered account whose relationship is handled# in person, and staff mailboxes on the company's own domain, look like# ordinary high-value clients and would otherwise be cold-emailed.supp_domains = {x.strip().lower().lstrip("@") for x in s.get("domains", [])}except Exception as ex:sys.exit(f"Suppression list unreadable ({ex}): fix {SUPPRESSION}; ""refusing to route without it.")cutoff = (dt.date.today() - dt.timedelta(days=365)).isoformat()root = ET.parse(XML_MAP).getroot()pending = []for c in root.findall("client"):plinth = (c.findtext("plinth_id") or "").strip()m = metrics.get(plinth)if m is None:cid = c.get("id") or ""if cid.startswith("pl-"):m = metrics.get(cid[3:])if m is None:continuetier = classify_one(m, cfg, True)partner = tier not in NON_PARTNERname = (c.findtext("full_name") or "").strip()email = (c.findtext("email") or "").strip().lower()blocks = [dict(key=(p.get("name") or p.get("key")),status=p.findtext("status") or "",stage=p.findtext("stage") or "",entered=p.findtext("entered_date") or "",touch=p.findtext("last_touch") or "")for p in c.findall("pipelines/pipeline")]lcd = ""fn = c.findtext("stub/file_name")if fn and (CLIENTS_DIR / fn).exists():mt = re.search(r"(?m)^last_contact_date: '?([\d-]+)'?",(CLIENTS_DIR / fn).read_text(encoding="utf-8"))if mt:lcd = mt.group(1)last = m["last_order_date"]dates = [b["entered"] for b in blocks] + [b["touch"] for b in blocks] + [lcd]row = dict(name=name, email=email, company=(c.findtext("company") or ""),tier=tier, ltv=m["ltv"], last_order=last or "",last_outreach=max([d for d in dates if d] or [""]),plinth_id=plinth, bitrix_id=(c.findtext("bitrix_id") or ""),chunk=c.get("id") or "",never_contacted=(not blocks and not lcd),role_name=bool(ROLE_NAME_RE.search(name)),stages="; ".join(f"{b['key']}:{b['stage']}({b['status']})"for b in blocks))dormant = (not last) or last < cutoffopen_deal = any(b["status"] == "active" for b in blocks)wb_done = any((b["entered"] or "0000") >= (last or "") or not b["entered"]for b in blocks if b["key"] == "winback")edom = email.rsplit("@", 1)[-1] if "@" in email else ""optout = (norm_key(name) in supp_names or email in supp_emailsor (edom and edom in supp_domains)or any(b["stage"] in OPTOUT_STAGES for b in blocks))pending.append((row, dormant, open_deal, wb_done, optout, partner))# Identity-level exclusion inheritance (the duplicate-twin case):# duplicate chunks for one person split the evidence. A Not-Interested closure# or open deal can sit on one twin while the blockless twin routes clean.# Exclusion evidence (opt-out / open deal / winback-done) is unioned across# every chunk sharing an email or a normalized full name, partner or not.# Inheritance only ever excludes; eligibility is never inherited.ev = {}def _bump(k, oo, od, wb):if not k:returncur = ev.get(k, (False, False, False))ev[k] = (cur[0] or oo, cur[1] or od, cur[2] or wb)for row, dormant, od, wb, oo, partner in pending:_bump(row["email"], oo, od, wb)_bump("n:" + norm_key(row["name"]), oo, od, wb)winback, cat24, dormq, excluded = [], [], [], []for row, dormant, od, wb, oo, partner in pending:if not partner:continuee1 = ev.get(row["email"], (False, False, False))e2 = ev.get("n:" + norm_key(row["name"]), (False, False, False))i_oo = oo or e1[0] or e2[0]i_od = od or e1[1] or e2[1]i_wb = wb or e1[2] or e2[2]why = []if i_oo:rdom = row["email"].rsplit("@", 1)[-1] if "@" in row["email"] else ""if rdom and rdom in supp_domains:why.append(f"never-contact domain @{rdom} ""(relationship handled directly, no cold outreach)")else:why.append("opt-out (unsubscribe or Not-Interested closure)"+ ("" if oo else ", inherited from identity twin"))if i_od and not why:why.append(("open deal: " + row["stages"]) if odelse "open deal on identity twin (duplicate chunk)")if dormant and i_wb and not why:why.append("winback already done this dormancy spell"+ ("" if wb else " (identity twin)"))if why:excluded.append({**row, "reason": " | ".join(why)})elif dormant:winback.append(row)elif row["never_contacted"]:cat24.append(row)else:dormq.append(row)winback.sort(key=lambda r: -r["ltv"])cat24.sort(key=lambda r: -r["ltv"])dormq.sort(key=lambda r: (r["last_outreach"] or "0000", -r["ltv"]))OUT_DIR.mkdir(parents=True, exist_ok=True)out_json = OUT_DIR / f"routing-candidates_{TODAY}.json"out_json.write_text(json.dumps({"winback": winback, "cat24": cat24,"dormant_queue": dormq, "excluded": excluded},indent=1), encoding="utf-8")# winback import CSV: person-named only; role-named to a review CSVdef _wcsv(path, rows):with path.open("w", newline="", encoding="utf-8-sig") as f:w = csv.writer(f)w.writerow(["Deal Name", "Sales Funnel", "Deal Stage", "Contact: First Name","Contact: Last Name", "Contact: Email", "Contact: Company","User Group", "Comment"])for r in rows:parts = r["name"].split(" ", 1)w.writerow([r["name"], "Winback", "Client", parts[0],parts[1] if len(parts) > 1 else "", r["email"], r["company"],r["tier"],f"Lifetime {r['ltv']:.2f} USD | last order "f"{r['last_order'] or 'unknown'} | Plinth id "f"{r['plinth_id'] or 'n/a'} | routed {TODAY}"])_wcsv(OUT_DIR / f"winback-import_{TODAY}.csv",[r for r in winback if not r["role_name"]])_wcsv(OUT_DIR / f"winback-role-review_{TODAY}.csv",[r for r in winback if r["role_name"]])print(f"ROUTE: winback {len(winback)} "f"({len([r for r in winback if r['role_name']])} role-named held for review) | "f"cat24 first-touch {len(cat24)} | dormant queue {len(dormq)} | "f"excluded {len(excluded)}")print(f"Artifacts: {out_json.name}, winback-import_{TODAY}.csv, "f"winback-role-review_{TODAY}.csv in {OUT_DIR}")print("Read-only on vault + Bitrix. Deal creation stays human-gated: winback CSV ""-> bulk push after review; cat24/dormant -> push after review. ""Tier crossings appear in classify output (user_group_correct changes).")