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

classify.py

Language Python7.4 KB193 lines

07-Automation/partner_pipeline/classify.py

  1. """classify and verify: tiers from lifetime metrics, and the no-shrink check."""
  2. from __future__ import annotations
  3. import csv
  4. import json
  5. import sys
  6. from pathlib import Path
  7. from .config import is_armed, load_config
  8. from .history import (apply_merges, compute_metrics, load_history, load_merge_registry,
  9. save_history, to_float)
  10. from .paths import OUT_DIR, TODAY
  11. from .stubs import index_existing_stubs, stub_status
  12. def tier_override_for(c: dict, cfg: dict) -> str | None:
  13. """Relationship-set tier, if this mailbox has one (partner-thresholds.md
  14. `tier_overrides.emails`). Wins over the computed band and over
  15. min_orders_to_qualify: an overridden account holds its tier at $0 spend.
  16. Scope is per-email on purpose; see the config comment before widening it."""
  17. ov = (cfg.get("tier_overrides") or {}).get("emails") or {}
  18. if not ov:
  19. return None
  20. email = (c.get("email") or "").strip().lower()
  21. return ov.get(email) if email else None
  22. def classify_one(c: dict, cfg: dict, armed: bool) -> str:
  23. if not armed:
  24. return "UNCLASSIFIED: thresholds not set"
  25. forced = tier_override_for(c, cfg)
  26. if forced:
  27. return forced
  28. min_orders = cfg.get("min_orders_to_qualify") or 0
  29. if c["total_orders_count"] < min_orders:
  30. return "Not a partner client"
  31. mode = cfg.get("mode", "ltv_bands")
  32. if mode == "ltv_bands":
  33. ltv = c["ltv"]
  34. for band in cfg.get("ltv_bands", []):
  35. lo, hi = band.get("min_ltv"), band.get("max_ltv")
  36. if lo is None:
  37. continue
  38. if ltv >= lo and (hi is None or ltv < hi):
  39. return band["tier"]
  40. return "Not a partner client"
  41. if mode == "simple_gate":
  42. g = cfg.get("simple_gate", {})
  43. val = c.get(g.get("signal", "ltv"), 0)
  44. return "Partner Client" if val >= (g.get("min_value") or 0) else "Not a partner client"
  45. if mode == "multi_factor":
  46. mf = cfg.get("multi_factor", {})
  47. w = mf.get("weights", {})
  48. score = (
  49. to_float(w.get("ltv")) * c["ltv"]
  50. + to_float(w.get("repeat_revenue")) * c["repeat_revenue"]
  51. + to_float(w.get("order_count")) * c["total_orders_count"]
  52. )
  53. if score < to_float(mf.get("partner_cutoff")):
  54. return "Not a partner client"
  55. for band in mf.get("score_bands", []) or []:
  56. lo, hi = band.get("min_score"), band.get("max_score")
  57. if lo is not None and score >= lo and (hi is None or score < hi):
  58. return band["tier"]
  59. return "Partner Client"
  60. return "Not a partner client"
  61. COLUMNS = [
  62. "user_id", "email", "first_name", "last_name", "company", "phone",
  63. "total_orders_count", "ltv", "repeat_revenue", "first_order_date",
  64. "last_order_date", "assigned_tier", "store_user_group", "has_baseline",
  65. "post_baseline_orders", "stub_status",
  66. ]
  67. def export_spreadsheet(rows: list[dict], summary: dict) -> Path:
  68. OUT_DIR.mkdir(parents=True, exist_ok=True)
  69. stem = OUT_DIR / f"partner-classification_{TODAY}"
  70. (stem.with_suffix(".plan.json")).write_text(
  71. json.dumps({"rows": rows, "summary": summary}, indent=2), encoding="utf-8"
  72. )
  73. try:
  74. from openpyxl import Workbook
  75. wb = Workbook()
  76. ws = wb.active
  77. ws.title = "Classification"
  78. ws.append(COLUMNS)
  79. for r in rows:
  80. ws.append([r.get(k, "") for k in COLUMNS])
  81. s = wb.create_sheet("Summary")
  82. for k, v in summary.items():
  83. s.append([k, json.dumps(v) if isinstance(v, (dict, list)) else v])
  84. out = stem.with_suffix(".xlsx")
  85. wb.save(out)
  86. return out
  87. except ImportError:
  88. out = stem.with_suffix(".csv")
  89. with out.open("w", newline="", encoding="utf-8") as f:
  90. w = csv.DictWriter(f, fieldnames=COLUMNS, extrasaction="ignore")
  91. w.writeheader()
  92. w.writerows(rows)
  93. print("NOTE: openpyxl not installed: wrote CSV instead of xlsx.")
  94. return out
  95. def cmd_classify(args):
  96. cfg = load_config()
  97. armed, reasons = is_armed(cfg)
  98. h = load_history()
  99. metrics = compute_metrics(h, cfg)
  100. aliases, _ = load_merge_registry()
  101. metrics, folded = apply_merges(metrics, aliases)
  102. if folded:
  103. print(f"MERGE REGISTRY: folded {len(folded)} absorbed account(s): "
  104. + ", ".join(folded))
  105. idx = index_existing_stubs()
  106. rows, tier_counts, status_counts = [], {}, {}
  107. for c in metrics.values():
  108. tier = classify_one(c, cfg, armed)
  109. status, _ = stub_status(c, idx)
  110. tier_counts[tier] = tier_counts.get(tier, 0) + 1
  111. status_counts[status] = status_counts.get(status, 0) + 1
  112. rows.append({**c, "assigned_tier": tier, "stub_status": status})
  113. rows.sort(key=lambda r: r["ltv"], reverse=True)
  114. summary = {
  115. "run_date": TODAY,
  116. "mode": cfg.get("mode"),
  117. "armed": armed,
  118. "not_armed_reasons": reasons,
  119. "customers": len(metrics),
  120. "baseline_as_of": h["meta"].get("baseline_as_of"),
  121. "last_ingest": h["meta"].get("last_ingest"),
  122. "tier_counts": tier_counts,
  123. "stub_status_counts": status_counts,
  124. }
  125. out = export_spreadsheet(rows, summary)
  126. # snapshot for the no-shrink check (keyed like the history store)
  127. snap = {k: {"ltv": m["ltv"], "n": m["total_orders_count"]} for k, m in metrics.items()}
  128. h["meta"].setdefault("computed_runs", []).append({"date": TODAY, "customers": len(metrics)})
  129. h["meta"]["previous_computed"], h["meta"]["current_computed"] = (
  130. h["meta"].get("current_computed", {}), snap)
  131. save_history(h)
  132. print(f"{'ARMED' if armed else 'READ-ONLY (thresholds not set)'}: "
  133. f"{len(metrics)} customers from history "
  134. f"(baseline as_of {h['meta'].get('baseline_as_of')}).")
  135. if not armed:
  136. print("Not armed:", "; ".join(reasons))
  137. print("Spreadsheet:", out)
  138. print("Plan JSON:", out.with_suffix("").with_suffix(".plan.json"))
  139. def cmd_verify(args):
  140. h = load_history()
  141. prev = h["meta"].get("previous_computed") or {}
  142. cur = h["meta"].get("current_computed") or {}
  143. if not cur:
  144. sys.exit("Nothing to verify: run classify first.")
  145. shrunk = []
  146. for key, now in cur.items():
  147. before = prev.get(key)
  148. if before and (now["ltv"] < before["ltv"] or now["n"] < before["n"]):
  149. c = h["customers"].get(key, {})
  150. shrunk.append({
  151. "key": key, "email": c.get("email"), "company": c.get("company"),
  152. "ltv_before": before["ltv"], "ltv_now": now["ltv"],
  153. "orders_before": before["n"], "orders_now": now["n"],
  154. })
  155. report = OUT_DIR / f"verify_{TODAY}.json"
  156. OUT_DIR.mkdir(parents=True, exist_ok=True)
  157. report.write_text(json.dumps({"run": TODAY, "shrunk": shrunk}, indent=2), encoding="utf-8")
  158. if shrunk:
  159. print(f"NO-SHRINK GUARDRAIL: {len(shrunk)} customer(s) DECREASED: "
  160. "lifetime history can only grow, so this means data went missing "
  161. "(truncated pull, status reversal, or a bad ingest).")
  162. for s in shrunk[:10]:
  163. print(f" {s['email'] or s['key']}: ltv {s['ltv_before']} -> {s['ltv_now']}, "
  164. f"orders {s['orders_before']} -> {s['orders_now']}")
  165. print(f"Full report: {report}")
  166. print("DO NOT demote tiers or move anyone to Winback off this run. "
  167. "Flag for Matt/Hazel review.")
  168. sys.exit(2)
  169. print(f"Verify OK: no customer shrank vs the previous run. Report: {report}")