classify.py
Language Python7.4 KB193 lines
"""classify and verify: tiers from lifetime metrics, and the no-shrink check."""from __future__ import annotationsimport csvimport jsonimport sysfrom pathlib import Pathfrom .config import is_armed, load_configfrom .history import (apply_merges, compute_metrics, load_history, load_merge_registry,save_history, to_float)from .paths import OUT_DIR, TODAYfrom .stubs import index_existing_stubs, stub_statusdef tier_override_for(c: dict, cfg: dict) -> str | None:"""Relationship-set tier, if this mailbox has one (partner-thresholds.md`tier_overrides.emails`). Wins over the computed band and overmin_orders_to_qualify: an overridden account holds its tier at $0 spend.Scope is per-email on purpose; see the config comment before widening it."""ov = (cfg.get("tier_overrides") or {}).get("emails") or {}if not ov:return Noneemail = (c.get("email") or "").strip().lower()return ov.get(email) if email else Nonedef classify_one(c: dict, cfg: dict, armed: bool) -> str:if not armed:return "UNCLASSIFIED: thresholds not set"forced = tier_override_for(c, cfg)if forced:return forcedmin_orders = cfg.get("min_orders_to_qualify") or 0if c["total_orders_count"] < min_orders:return "Not a partner client"mode = cfg.get("mode", "ltv_bands")if mode == "ltv_bands":ltv = c["ltv"]for band in cfg.get("ltv_bands", []):lo, hi = band.get("min_ltv"), band.get("max_ltv")if lo is None:continueif ltv >= lo and (hi is None or ltv < hi):return band["tier"]return "Not a partner client"if mode == "simple_gate":g = cfg.get("simple_gate", {})val = c.get(g.get("signal", "ltv"), 0)return "Partner Client" if val >= (g.get("min_value") or 0) else "Not a partner client"if mode == "multi_factor":mf = cfg.get("multi_factor", {})w = mf.get("weights", {})score = (to_float(w.get("ltv")) * c["ltv"]+ to_float(w.get("repeat_revenue")) * c["repeat_revenue"]+ to_float(w.get("order_count")) * c["total_orders_count"])if score < to_float(mf.get("partner_cutoff")):return "Not a partner client"for band in mf.get("score_bands", []) or []:lo, hi = band.get("min_score"), band.get("max_score")if lo is not None and score >= lo and (hi is None or score < hi):return band["tier"]return "Partner Client"return "Not a partner client"COLUMNS = ["user_id", "email", "first_name", "last_name", "company", "phone","total_orders_count", "ltv", "repeat_revenue", "first_order_date","last_order_date", "assigned_tier", "store_user_group", "has_baseline","post_baseline_orders", "stub_status",]def export_spreadsheet(rows: list[dict], summary: dict) -> Path:OUT_DIR.mkdir(parents=True, exist_ok=True)stem = OUT_DIR / f"partner-classification_{TODAY}"(stem.with_suffix(".plan.json")).write_text(json.dumps({"rows": rows, "summary": summary}, indent=2), encoding="utf-8")try:from openpyxl import Workbookwb = Workbook()ws = wb.activews.title = "Classification"ws.append(COLUMNS)for r in rows:ws.append([r.get(k, "") for k in COLUMNS])s = wb.create_sheet("Summary")for k, v in summary.items():s.append([k, json.dumps(v) if isinstance(v, (dict, list)) else v])out = stem.with_suffix(".xlsx")wb.save(out)return outexcept ImportError:out = stem.with_suffix(".csv")with out.open("w", newline="", encoding="utf-8") as f:w = csv.DictWriter(f, fieldnames=COLUMNS, extrasaction="ignore")w.writeheader()w.writerows(rows)print("NOTE: openpyxl not installed: wrote CSV instead of xlsx.")return outdef cmd_classify(args):cfg = load_config()armed, reasons = is_armed(cfg)h = load_history()metrics = compute_metrics(h, cfg)aliases, _ = load_merge_registry()metrics, folded = apply_merges(metrics, aliases)if folded:print(f"MERGE REGISTRY: folded {len(folded)} absorbed account(s): "+ ", ".join(folded))idx = index_existing_stubs()rows, tier_counts, status_counts = [], {}, {}for c in metrics.values():tier = classify_one(c, cfg, armed)status, _ = stub_status(c, idx)tier_counts[tier] = tier_counts.get(tier, 0) + 1status_counts[status] = status_counts.get(status, 0) + 1rows.append({**c, "assigned_tier": tier, "stub_status": status})rows.sort(key=lambda r: r["ltv"], reverse=True)summary = {"run_date": TODAY,"mode": cfg.get("mode"),"armed": armed,"not_armed_reasons": reasons,"customers": len(metrics),"baseline_as_of": h["meta"].get("baseline_as_of"),"last_ingest": h["meta"].get("last_ingest"),"tier_counts": tier_counts,"stub_status_counts": status_counts,}out = export_spreadsheet(rows, summary)# snapshot for the no-shrink check (keyed like the history store)snap = {k: {"ltv": m["ltv"], "n": m["total_orders_count"]} for k, m in metrics.items()}h["meta"].setdefault("computed_runs", []).append({"date": TODAY, "customers": len(metrics)})h["meta"]["previous_computed"], h["meta"]["current_computed"] = (h["meta"].get("current_computed", {}), snap)save_history(h)print(f"{'ARMED' if armed else 'READ-ONLY (thresholds not set)'}: "f"{len(metrics)} customers from history "f"(baseline as_of {h['meta'].get('baseline_as_of')}).")if not armed:print("Not armed:", "; ".join(reasons))print("Spreadsheet:", out)print("Plan JSON:", out.with_suffix("").with_suffix(".plan.json"))def cmd_verify(args):h = load_history()prev = h["meta"].get("previous_computed") or {}cur = h["meta"].get("current_computed") or {}if not cur:sys.exit("Nothing to verify: run classify first.")shrunk = []for key, now in cur.items():before = prev.get(key)if before and (now["ltv"] < before["ltv"] or now["n"] < before["n"]):c = h["customers"].get(key, {})shrunk.append({"key": key, "email": c.get("email"), "company": c.get("company"),"ltv_before": before["ltv"], "ltv_now": now["ltv"],"orders_before": before["n"], "orders_now": now["n"],})report = OUT_DIR / f"verify_{TODAY}.json"OUT_DIR.mkdir(parents=True, exist_ok=True)report.write_text(json.dumps({"run": TODAY, "shrunk": shrunk}, indent=2), encoding="utf-8")if shrunk:print(f"NO-SHRINK GUARDRAIL: {len(shrunk)} customer(s) DECREASED: ""lifetime history can only grow, so this means data went missing ""(truncated pull, status reversal, or a bad ingest).")for s in shrunk[:10]:print(f" {s['email'] or s['key']}: ltv {s['ltv_before']} -> {s['ltv_now']}, "f"orders {s['orders_before']} -> {s['orders_now']}")print(f"Full report: {report}")print("DO NOT demote tiers or move anyone to Winback off this run. ""Flag for Matt/Hazel review.")sys.exit(2)print(f"Verify OK: no customer shrank vs the previous run. Report: {report}")