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

stubs.py

Language Python7.6 KB190 lines

07-Automation/partner_pipeline/stubs.py

  1. """Client stubs: index, create, upgrade, and mirror pipeline blocks."""
  2. from __future__ import annotations
  3. import json
  4. import re
  5. from pathlib import Path
  6. import yaml
  7. from .config import NON_PARTNER, guard_or_exit
  8. from .history import to_float
  9. from .paths import CLIENTS_DIR, TODAY
  10. def safe_stub_name(first, last, email="") -> str:
  11. """Filesystem-safe stub filename. Names come from Plinth order fields and can
  12. contain slashes or shipping-label junk that would otherwise break the path."""
  13. raw = f"{(first or '').strip()} {(last or '').strip()}".strip() or (email or "").strip()
  14. safe = re.sub(r'[/\\:*?"<>|\n\r\t]+', '-', raw) # kill path/illegal chars
  15. safe = re.sub(r'\s+', ' ', safe).strip(' .-') # tidy whitespace and edges
  16. return safe[:80].strip(' .-') or "unknown"
  17. def _read_frontmatter(path: Path) -> dict:
  18. try:
  19. text = path.read_text(encoding="utf-8")
  20. except Exception:
  21. return {}
  22. m = re.match(r"^---\n(.*?)\n---", text, re.DOTALL)
  23. if not m:
  24. return {}
  25. try:
  26. return yaml.safe_load(m.group(1)) or {}
  27. except yaml.YAMLError:
  28. return {}
  29. def index_existing_stubs() -> dict:
  30. by_email, by_name = {}, {}
  31. if not CLIENTS_DIR.exists():
  32. return {"by_email": by_email, "by_name": by_name}
  33. for p in CLIENTS_DIR.glob("*.md"):
  34. fm = _read_frontmatter(p)
  35. if not fm:
  36. continue
  37. rec = {"path": p, "fm": fm}
  38. email = (str(fm.get("email") or "")).strip().lower()
  39. if email:
  40. by_email[email] = rec
  41. fn = (str(fm.get("first_name") or "")).strip().lower()
  42. ln = (str(fm.get("last_name") or "")).strip().lower()
  43. if fn or ln:
  44. by_name[f"{fn} {ln}".strip()] = rec
  45. return {"by_email": by_email, "by_name": by_name}
  46. def stub_status(c: dict, idx: dict) -> tuple[str, Path | None]:
  47. email = c["email"]
  48. name = f"{c['first_name']} {c['last_name']}".strip().lower()
  49. rec = idx["by_email"].get(email) or idx["by_name"].get(name)
  50. if not rec:
  51. return "new", None
  52. fm = rec["fm"]
  53. changed = (
  54. to_float(fm.get("ltv")) != c["ltv"]
  55. or int(fm.get("total_orders_count") or 0) != c["total_orders_count"]
  56. )
  57. return ("exists-changed" if changed else "exists"), rec["path"]
  58. def cmd_write_stubs(args):
  59. guard_or_exit()
  60. plan = json.loads(Path(args.plan).read_text(encoding="utf-8"))
  61. idx = index_existing_stubs()
  62. created, updated, flagged = [], [], []
  63. for r in plan["rows"]:
  64. if r["assigned_tier"] in NON_PARTNER:
  65. continue
  66. email = r["email"]
  67. name = f"{(r.get('first_name') or '')} {(r.get('last_name') or '')}".strip()
  68. rec = idx["by_email"].get(email) or idx["by_name"].get(name.lower())
  69. if rec:
  70. _upgrade_stub(rec["path"], r)
  71. updated.append(rec["path"].name)
  72. else:
  73. path = _create_stub(r)
  74. created.append(path.name)
  75. if re.search(r'[/\\:]|c/o|booth|attn', name, re.IGNORECASE):
  76. flagged.append(f"{name} -> {path.name}")
  77. print(f"Stubs created: {len(created)}; upgraded: {len(updated)}")
  78. if flagged:
  79. print(f"\nFLAGGED: {len(flagged)} stub(s) created from messy Plinth name fields "
  80. "(filename sanitized; review and rename or delete if not a real client):")
  81. for f in flagged:
  82. print(" ", f)
  83. print("REMINDER: append one LEDGER entry for this change-set (attribution by session email).")
  84. def _create_stub(r: dict) -> Path:
  85. display = f"{(r.get('first_name') or '')} {(r.get('last_name') or '')}".strip() or r["email"]
  86. fname = safe_stub_name(r.get("first_name"), r.get("last_name"), r["email"])
  87. path = CLIENTS_DIR / f"{fname}.md"
  88. if path.exists(): # don't clobber an existing same-name stub
  89. path = CLIENTS_DIR / f"{fname} ({r.get('user_id', '')}).md"
  90. fm = {
  91. "type": "client",
  92. "bitrix_id": "",
  93. "first_name": r["first_name"],
  94. "last_name": r["last_name"],
  95. "email": r["email"],
  96. "user_group": "",
  97. "user_group_correct": r["assigned_tier"],
  98. "industry": "",
  99. "company": r.get("company", ""),
  100. "phone": r.get("phone", ""),
  101. "ltv": f"${r['ltv']:,.2f}",
  102. "repeat_revenue": f"${r['repeat_revenue']:,.2f}",
  103. "title": "",
  104. "company_website": "",
  105. "total_orders_count": r["total_orders_count"],
  106. "last_order_date": r["last_order_date"],
  107. "source": "weekly_partner_pipeline",
  108. "last_modified": TODAY,
  109. "tags": ["client", "partner-pipeline"],
  110. }
  111. body = (
  112. f"\n# {display}\n\n## Snapshot\n| Field | Value |\n|-------|-------|\n"
  113. f"| Title | [TBD] |\n| Company | {r.get('company','')} |\n"
  114. f"| Email | {r['email']} |\n| Phone | {r.get('phone','')} |\n\n"
  115. "## Order History\n"
  116. f"- Total orders: {r['total_orders_count']}\n"
  117. f"- Lifetime value: ${r['ltv']:,.2f}\n"
  118. f"- Repeat revenue: ${r['repeat_revenue']:,.2f}\n"
  119. f"- Last order: {r['last_order_date']}\n\n"
  120. "## Research\n<!-- Enriched via client-enrich. Never overwrite Human Notes. -->\n"
  121. "### Company\n[TBD]\n### Person / LinkedIn\n[TBD]\n### Recent News & Signals\n[TBD]\n### Sources\n\n"
  122. "## Human Notes\n<!-- Matt's space. Automation never writes here. -->\n\n"
  123. "## Outreach Log\n| Date | Pipeline | Channel | Template | Outcome | Next step |\n"
  124. "|------|----------|---------|----------|---------|-----------|\n"
  125. )
  126. path.write_text("---\n" + yaml.safe_dump(fm, sort_keys=False, allow_unicode=True) + "---\n" + body,
  127. encoding="utf-8")
  128. return path
  129. def _upgrade_stub(path: Path, r: dict):
  130. """Update only computed frontmatter + last_modified. Never touch Human Notes/Research."""
  131. text = path.read_text(encoding="utf-8")
  132. m = re.match(r"^---\n(.*?)\n---(.*)$", text, re.DOTALL)
  133. if not m:
  134. return
  135. fm = yaml.safe_load(m.group(1)) or {}
  136. fm["ltv"] = f"${r['ltv']:,.2f}"
  137. fm["repeat_revenue"] = f"${r['repeat_revenue']:,.2f}"
  138. fm["total_orders_count"] = r["total_orders_count"]
  139. fm["last_order_date"] = r["last_order_date"]
  140. fm["user_group_correct"] = r["assigned_tier"]
  141. fm["last_modified"] = TODAY
  142. path.write_text("---\n" + yaml.safe_dump(fm, sort_keys=False, allow_unicode=True) + "---" + m.group(2),
  143. encoding="utf-8")
  144. def mirror_stub_pipeline(stub_path: Path, key: str, stage: str, status: str, entered: str):
  145. """Copy a census pipeline block into the stub's `outreach_pipelines` frontmatter.
  146. Returns True when the stub changed. A failure warns and never stops the run."""
  147. try:
  148. txt = stub_path.read_text(encoding="utf-8")
  149. m = re.match(r"^---\n(.*?\n)---(.*)$", txt, re.DOTALL)
  150. if not m:
  151. return False
  152. fm = yaml.safe_load(m.group(1)) or {}
  153. op = fm.get("outreach_pipelines") or {}
  154. e = op.get(key) or {}
  155. if e.get("stage") == stage and e.get("status") == status:
  156. return False
  157. e.update({"stage": stage, "status": status})
  158. if not e.get("entered"):
  159. e["entered"] = entered or None
  160. for f in ("last_touch", "next_step", "next_date"):
  161. e.setdefault(f, None)
  162. op[key] = e
  163. fm["outreach_pipelines"] = op
  164. fm["last_modified"] = TODAY
  165. stub_path.write_text("---\n" + yaml.safe_dump(fm, sort_keys=False,
  166. allow_unicode=True) + "---" + m.group(2), encoding="utf-8")
  167. return True
  168. except Exception as ex:
  169. print(f"WARNING: stub mirror failed for {stub_path.name}: {ex}")
  170. return False