mattrguerra/meridian-outreach-vaultmainPrivate on GitHub, published here in full

route.py

Language Python9.3 KB186 lines

07-Automation/partner_pipeline/route.py

  1. """route: the weekly outreach candidate lists."""
  2. from __future__ import annotations
  3. import csv
  4. import datetime as dt
  5. import json
  6. import re
  7. import sys
  8. from .census import norm_key
  9. from .classify import classify_one
  10. from .config import NON_PARTNER, is_armed, load_config
  11. from .history import apply_merges, compute_metrics, load_history, load_merge_registry
  12. from .paths import CLIENTS_DIR, OUT_DIR, SUPPRESSION, TODAY, XML_MAP
  13. from .stages import OPTOUT_STAGES
  14. ROLE_NAME_RE = re.compile(
  15. r"\b(test|testing|marketing|vendors?|department|dept|purchasing|expense|admin|office|"
  16. r"accounts?|accounting|info|sales|creatives?|design|graphics|print|shop|store|team|"
  17. r"staff|mngt|management|hr|billing)\b", re.I)
  18. def cmd_route(args):
  19. """Evaluate pipeline-routing-rules.md against the census. Read-only on the vault;
  20. writes candidate artifacts to 05-Outputs/partner-runs/. Never touches Bitrix.
  21. Rules: Winback first (partner, no order 12+ months, one touch per dormancy
  22. spell); cat 24 = active never-contacted + tier crossings; Dormant queue
  23. = active, previously contacted, no open deal, longest-since-outreach first. Opt-outs
  24. (suppression file + any Not-Interested closure) never route anywhere."""
  25. import xml.etree.ElementTree as ET
  26. cfg = load_config()
  27. armed, reasons = is_armed(cfg)
  28. if not armed:
  29. sys.exit("REFUSED: thresholds not set (" + "; ".join(reasons) + ")")
  30. h = load_history()
  31. metrics = compute_metrics(h, cfg)
  32. aliases, _ = load_merge_registry()
  33. metrics, _ = apply_merges(metrics, aliases)
  34. supp_names, supp_emails, supp_domains = set(), set(), set()
  35. if SUPPRESSION.exists():
  36. try:
  37. s = json.loads(SUPPRESSION.read_text(encoding="utf-8"))
  38. supp_names = {norm_key(x) for x in s.get("names", [])}
  39. supp_emails = {x.strip().lower() for x in s.get("emails", [])}
  40. # Whole-domain never-contact list. Screening role-shaped names cannot see
  41. # a domain: a directly partnered account whose relationship is handled
  42. # in person, and staff mailboxes on the company's own domain, look like
  43. # ordinary high-value clients and would otherwise be cold-emailed.
  44. supp_domains = {x.strip().lower().lstrip("@") for x in s.get("domains", [])}
  45. except Exception as ex:
  46. sys.exit(f"Suppression list unreadable ({ex}): fix {SUPPRESSION}; "
  47. "refusing to route without it.")
  48. cutoff = (dt.date.today() - dt.timedelta(days=365)).isoformat()
  49. root = ET.parse(XML_MAP).getroot()
  50. pending = []
  51. for c in root.findall("client"):
  52. plinth = (c.findtext("plinth_id") or "").strip()
  53. m = metrics.get(plinth)
  54. if m is None:
  55. cid = c.get("id") or ""
  56. if cid.startswith("pl-"):
  57. m = metrics.get(cid[3:])
  58. if m is None:
  59. continue
  60. tier = classify_one(m, cfg, True)
  61. partner = tier not in NON_PARTNER
  62. name = (c.findtext("full_name") or "").strip()
  63. email = (c.findtext("email") or "").strip().lower()
  64. blocks = [dict(key=(p.get("name") or p.get("key")),
  65. status=p.findtext("status") or "",
  66. stage=p.findtext("stage") or "",
  67. entered=p.findtext("entered_date") or "",
  68. touch=p.findtext("last_touch") or "")
  69. for p in c.findall("pipelines/pipeline")]
  70. lcd = ""
  71. fn = c.findtext("stub/file_name")
  72. if fn and (CLIENTS_DIR / fn).exists():
  73. mt = re.search(r"(?m)^last_contact_date: '?([\d-]+)'?",
  74. (CLIENTS_DIR / fn).read_text(encoding="utf-8"))
  75. if mt:
  76. lcd = mt.group(1)
  77. last = m["last_order_date"]
  78. dates = [b["entered"] for b in blocks] + [b["touch"] for b in blocks] + [lcd]
  79. row = dict(name=name, email=email, company=(c.findtext("company") or ""),
  80. tier=tier, ltv=m["ltv"], last_order=last or "",
  81. last_outreach=max([d for d in dates if d] or [""]),
  82. plinth_id=plinth, bitrix_id=(c.findtext("bitrix_id") or ""),
  83. chunk=c.get("id") or "",
  84. never_contacted=(not blocks and not lcd),
  85. role_name=bool(ROLE_NAME_RE.search(name)),
  86. stages="; ".join(f"{b['key']}:{b['stage']}({b['status']})"
  87. for b in blocks))
  88. dormant = (not last) or last < cutoff
  89. open_deal = any(b["status"] == "active" for b in blocks)
  90. wb_done = any((b["entered"] or "0000") >= (last or "") or not b["entered"]
  91. for b in blocks if b["key"] == "winback")
  92. edom = email.rsplit("@", 1)[-1] if "@" in email else ""
  93. optout = (norm_key(name) in supp_names or email in supp_emails
  94. or (edom and edom in supp_domains)
  95. or any(b["stage"] in OPTOUT_STAGES for b in blocks))
  96. pending.append((row, dormant, open_deal, wb_done, optout, partner))
  97. # Identity-level exclusion inheritance (the duplicate-twin case):
  98. # duplicate chunks for one person split the evidence. A Not-Interested closure
  99. # or open deal can sit on one twin while the blockless twin routes clean.
  100. # Exclusion evidence (opt-out / open deal / winback-done) is unioned across
  101. # every chunk sharing an email or a normalized full name, partner or not.
  102. # Inheritance only ever excludes; eligibility is never inherited.
  103. ev = {}
  104. def _bump(k, oo, od, wb):
  105. if not k:
  106. return
  107. cur = ev.get(k, (False, False, False))
  108. ev[k] = (cur[0] or oo, cur[1] or od, cur[2] or wb)
  109. for row, dormant, od, wb, oo, partner in pending:
  110. _bump(row["email"], oo, od, wb)
  111. _bump("n:" + norm_key(row["name"]), oo, od, wb)
  112. winback, cat24, dormq, excluded = [], [], [], []
  113. for row, dormant, od, wb, oo, partner in pending:
  114. if not partner:
  115. continue
  116. e1 = ev.get(row["email"], (False, False, False))
  117. e2 = ev.get("n:" + norm_key(row["name"]), (False, False, False))
  118. i_oo = oo or e1[0] or e2[0]
  119. i_od = od or e1[1] or e2[1]
  120. i_wb = wb or e1[2] or e2[2]
  121. why = []
  122. if i_oo:
  123. rdom = row["email"].rsplit("@", 1)[-1] if "@" in row["email"] else ""
  124. if rdom and rdom in supp_domains:
  125. why.append(f"never-contact domain @{rdom} "
  126. "(relationship handled directly, no cold outreach)")
  127. else:
  128. why.append("opt-out (unsubscribe or Not-Interested closure)"
  129. + ("" if oo else ", inherited from identity twin"))
  130. if i_od and not why:
  131. why.append(("open deal: " + row["stages"]) if od
  132. else "open deal on identity twin (duplicate chunk)")
  133. if dormant and i_wb and not why:
  134. why.append("winback already done this dormancy spell"
  135. + ("" if wb else " (identity twin)"))
  136. if why:
  137. excluded.append({**row, "reason": " | ".join(why)})
  138. elif dormant:
  139. winback.append(row)
  140. elif row["never_contacted"]:
  141. cat24.append(row)
  142. else:
  143. dormq.append(row)
  144. winback.sort(key=lambda r: -r["ltv"])
  145. cat24.sort(key=lambda r: -r["ltv"])
  146. dormq.sort(key=lambda r: (r["last_outreach"] or "0000", -r["ltv"]))
  147. OUT_DIR.mkdir(parents=True, exist_ok=True)
  148. out_json = OUT_DIR / f"routing-candidates_{TODAY}.json"
  149. out_json.write_text(json.dumps({"winback": winback, "cat24": cat24,
  150. "dormant_queue": dormq, "excluded": excluded},
  151. indent=1), encoding="utf-8")
  152. # winback import CSV: person-named only; role-named to a review CSV
  153. def _wcsv(path, rows):
  154. with path.open("w", newline="", encoding="utf-8-sig") as f:
  155. w = csv.writer(f)
  156. w.writerow(["Deal Name", "Sales Funnel", "Deal Stage", "Contact: First Name",
  157. "Contact: Last Name", "Contact: Email", "Contact: Company",
  158. "User Group", "Comment"])
  159. for r in rows:
  160. parts = r["name"].split(" ", 1)
  161. w.writerow([r["name"], "Winback", "Client", parts[0],
  162. parts[1] if len(parts) > 1 else "", r["email"], r["company"],
  163. r["tier"],
  164. f"Lifetime {r['ltv']:.2f} USD | last order "
  165. f"{r['last_order'] or 'unknown'} | Plinth id "
  166. f"{r['plinth_id'] or 'n/a'} | routed {TODAY}"])
  167. _wcsv(OUT_DIR / f"winback-import_{TODAY}.csv",
  168. [r for r in winback if not r["role_name"]])
  169. _wcsv(OUT_DIR / f"winback-role-review_{TODAY}.csv",
  170. [r for r in winback if r["role_name"]])
  171. print(f"ROUTE: winback {len(winback)} "
  172. f"({len([r for r in winback if r['role_name']])} role-named held for review) | "
  173. f"cat24 first-touch {len(cat24)} | dormant queue {len(dormq)} | "
  174. f"excluded {len(excluded)}")
  175. print(f"Artifacts: {out_json.name}, winback-import_{TODAY}.csv, "
  176. f"winback-role-review_{TODAY}.csv in {OUT_DIR}")
  177. print("Read-only on vault + Bitrix. Deal creation stays human-gated: winback CSV "
  178. "-> bulk push after review; cat24/dormant -> push after review. "
  179. "Tier crossings appear in classify output (user_group_correct changes).")