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

run_sample.py

Language Python8.2 KB183 lines

07-Automation/sample/run_sample.py

  1. #!/usr/bin/env python3
  2. """
  3. run_sample.py: replay the sample's two Monday runs of the weekly pipeline.
  4. Everything the vault ships under 03-Clients/, 05-Outputs/ (runs and audit) and
  5. 07-Automation/data/ came out of this replay, apart from the research added by hand
  6. to ten stubs, which a replay does not regenerate. It does what the scheduled Claude
  7. session does in the real run (see scheduled-task.md), with the MCP calls stood
  8. in for by files from sample/inputs/:
  9. 0. once: seed the baseline from the roster workbook in two passes, copy it as
  10. the first Drive-swap base, and start an empty census
  11. 1. per run date, oldest first:
  12. a. pull each day of the trailing window from Plinth and ingest it, paging
  13. until a page adds nothing (the convergence loop; a missing next page
  14. stands for a response that adds nothing)
  15. b. classify, verify (exit 2 stops the run), write-stubs, refresh-xml,
  16. refresh-workbook
  17. c. reconcile-deals and route against that week's Bitrix enumeration
  18. d. outreach-pull-list, then attach stage histories and contacts only for
  19. the deals it asks for (the get_stage_history / get_record step), then
  20. outreach-audit
  21. The clock is pinned to each run date with freezegun, because the driver dates
  22. its outputs by today's date. That is the only thing this script changes.
  23. Usage (from the vault root, on a copy with the generated state removed; the
  24. README's "Replay the sample" has the exact commands):
  25. pip install -r 07-Automation/requirements.txt
  26. python3 07-Automation/sample/generate_sample.py
  27. python3 07-Automation/sample/run_sample.py
  28. """
  29. from __future__ import annotations
  30. import argparse
  31. import datetime as dt
  32. import json
  33. import re
  34. import shutil
  35. import subprocess
  36. import sys
  37. from pathlib import Path
  38. HERE = Path(__file__).resolve().parent
  39. VAULT = HERE.parent.parent
  40. DRIVER = HERE.parent / "weekly_partner_pipeline.py"
  41. CENSUS = "03-Clients/partner-client-master-list.xml"
  42. CENSUS_ROOT = "partner_client_master_list"
  43. ALL_TAB = "All Accounts"
  44. PARTNER_TAB = "Complete Partner Clients List"
  45. BASE_PREFIX = "Complete-Partner-Clients-List_updated-"
  46. ROSTER_GLOB = "Partner-Clients-Roster_*.xlsx"
  47. BASELINE_AS_OF = dt.date(2026, 9, 7)
  48. RUN_DATES = [dt.date(2026, 9, 14), dt.date(2026, 9, 21)]
  49. PULL_WINDOW_DAYS = 8
  50. SUSPECT_ROWS = 50
  51. BOOT = ("import sys, runpy, freezegun\n"
  52. "freezegun.freeze_time(sys.argv[1]).start()\n"
  53. "sys.argv = sys.argv[2:]\n"
  54. "runpy.run_path(sys.argv[0], run_name='__main__')\n")
  55. class Run:
  56. def __init__(self, vault: Path, inputs: Path, driver: Path):
  57. self.vault, self.inputs, self.driver = vault, inputs, driver
  58. self.out = vault / "05-Outputs" / "partner-runs"
  59. self.log: list[str] = []
  60. def drive(self, when: str, *args: str, ok=(0,)) -> str:
  61. cmd = [sys.executable, "-c", BOOT, when, str(self.driver), *args]
  62. p = subprocess.run(cmd, cwd=self.vault, capture_output=True, text=True)
  63. # Logs are kept in the vault, so print paths relative to it.
  64. text = (p.stdout + p.stderr).replace(f"{self.vault}/", "")
  65. shown = " ".join(a if " " not in a else f'"{a}"' for a in args)
  66. self.log.append(f"$ weekly_partner_pipeline.py {shown}\n{text.rstrip()}\n")
  67. print(f" {args[0]:<20} exit {p.returncode}")
  68. if p.returncode not in ok:
  69. print(text)
  70. sys.exit(f"stopped: {args[0]} exited {p.returncode}")
  71. return text
  72. def seed(self):
  73. roster = sorted((self.inputs).glob(ROSTER_GLOB))[-1]
  74. data = self.vault / "07-Automation" / "data"
  75. data.mkdir(parents=True, exist_ok=True)
  76. for f in ("merge_registry.json", "suppression_list.json"):
  77. if not (data / f).exists():
  78. shutil.copy2(self.inputs / f, data / f)
  79. rel = roster.relative_to(self.vault) if roster.is_relative_to(self.vault) else roster
  80. when = f"{BASELINE_AS_OF.isoformat()} 18:00:00"
  81. self.drive(when, "seed-baseline", "--sheet", str(rel), "--tab", ALL_TAB,
  82. "--as-of", BASELINE_AS_OF.isoformat())
  83. self.drive(when, "seed-baseline", "--sheet", str(rel), "--tab", PARTNER_TAB,
  84. "--as-of", BASELINE_AS_OF.isoformat(), "--force")
  85. self.out.mkdir(parents=True, exist_ok=True)
  86. shutil.copy2(roster, self.out / f"{BASE_PREFIX}{BASELINE_AS_OF.isoformat()}.xlsx")
  87. (self.vault / "03-Clients" / "client-stubs").mkdir(parents=True, exist_ok=True)
  88. census = self.vault / CENSUS
  89. if not census.exists():
  90. census.parent.mkdir(parents=True, exist_ok=True)
  91. census.write_text("<?xml version='1.0' encoding='UTF-8'?>\n"
  92. f'<{CENSUS_ROOT} schema_version="3.0" />\n', encoding="utf-8")
  93. def pull(self, run_date: dt.date, when: str):
  94. raw = self.out / "raw" / run_date.isoformat()
  95. raw.mkdir(parents=True, exist_ok=True)
  96. for back in range(PULL_WINDOW_DAYS - 1, -1, -1):
  97. day = run_date - dt.timedelta(days=back)
  98. page = 0
  99. while True:
  100. src = self.inputs / "plinth" / f"{day.isoformat()}_p{page}.json"
  101. if not src.exists():
  102. break
  103. dst = raw / src.name
  104. shutil.copy2(src, dst)
  105. text = self.drive(when, "ingest", "--orders", str(dst.relative_to(self.vault)))
  106. m = re.search(r"parsed=(\d+) added=(\d+)", text)
  107. parsed, added = (int(m.group(1)), int(m.group(2))) if m else (0, 0)
  108. # Keep paging while a page adds orders or looks truncated.
  109. if added == 0 and parsed < SUSPECT_ROWS:
  110. break
  111. page += 1
  112. def week(self, run_date: dt.date):
  113. d = run_date.isoformat()
  114. when = f"{d} 06:00:00"
  115. print(f"run {d}")
  116. self.pull(run_date, when)
  117. self.drive(when, "classify")
  118. self.drive(when, "verify")
  119. plan = f"05-Outputs/partner-runs/partner-classification_{d}.plan.json"
  120. self.drive(when, "write-stubs", "--plan", plan)
  121. self.drive(when, "refresh-xml", "--plan", plan)
  122. self.drive(when, "refresh-workbook", "--plan", plan)
  123. deals_in = self.inputs / "bitrix" / f"bitrix-deals_{d}.json"
  124. deals = self.out / f"bitrix-deals_{d}.json"
  125. shutil.copy2(deals_in, deals)
  126. rel = str(deals.relative_to(self.vault))
  127. self.drive(when, "reconcile-deals", "--deals", rel)
  128. self.drive(when, "route")
  129. self.drive(when, "outreach-pull-list", "--deals", rel)
  130. pl = json.loads((self.out / f"outreach-pull-list_{d}.json").read_text(encoding="utf-8"))
  131. hist = json.loads((self.inputs / "bitrix" / "stage-histories.json").read_text(encoding="utf-8"))
  132. cons = json.loads((self.inputs / "bitrix" / "contacts.json").read_text(encoding="utf-8"))
  133. need_h, need_c = set(pl["need_history"]), set(pl["need_contact"])
  134. body = json.loads(deals.read_text(encoding="utf-8"))
  135. for x in body["deals"]:
  136. did = x["deal_id"]
  137. if did in need_h:
  138. x["history"] = [e for e in hist.get(did, []) if e["at"][:10] <= d]
  139. if did in need_c and did in cons:
  140. x["contact"] = cons[did]
  141. deals.write_text(json.dumps(body, indent=1), encoding="utf-8")
  142. self.drive(when, "outreach-audit", "--deals", rel)
  143. (self.out / f"run-log_{d}.txt").write_text("\n".join(self.log), encoding="utf-8")
  144. self.log = []
  145. def main():
  146. ap = argparse.ArgumentParser(description="Replay the sample's two weekly runs.")
  147. ap.add_argument("--vault", default=str(VAULT))
  148. ap.add_argument("--inputs", default=str(HERE / "inputs"))
  149. ap.add_argument("--driver", default=str(DRIVER))
  150. a = ap.parse_args()
  151. r = Run(Path(a.vault).resolve(), Path(a.inputs).resolve(), Path(a.driver).resolve())
  152. if (r.vault / "07-Automation" / "data" / "order_history.json").exists():
  153. sys.exit("order_history.json already exists: this replay expects an unseeded vault "
  154. "(see the README, 'Replay the sample').")
  155. print("seed")
  156. r.seed()
  157. (r.out / f"run-log_{BASELINE_AS_OF.isoformat()}-seed.txt").write_text("\n".join(r.log), encoding="utf-8")
  158. r.log = []
  159. for day in RUN_DATES:
  160. r.week(day)
  161. print("done")
  162. if __name__ == "__main__":
  163. main()