mattrguerra/meridian-skillsmainPrivate on GitHub, published here in full

batch_enrich.py

Language Python7.0 KB163 lines

skills/client-enrich/scripts/batch_enrich.py

  1. #!/usr/bin/env python3
  2. """
  3. batch_enrich.py: deterministic batch write-back for client-enrich (posts + frontmatter mode).
  4. Takes a list of stubs and a flat list of LinkedIn company posts, groups posts by
  5. company slug, auto-selects the most outreach-relevant ones, builds a Research
  6. payload per stub (Person from existing frontmatter, Company minimal, Signals from
  7. posts, Sources), and calls enrich_writeback.py for each stub so the vault contract
  8. is enforced (writes only under ## Research, never Human Notes).
  9. Usage:
  10. python3 batch_enrich.py --batch checkin_batch.json --posts posts.json \
  11. --stubdir "/path/to/03-Clients/Check-In" --script "/path/to/enrich_writeback.py" [--date YYYY-MM-DD]
  12. checkin_batch.json: [{file,email,linkedin,company,company_li,slug,title,seniority,city,state}, ...]
  13. posts.json: [{targetUrl, content, date, type, authorName, authorType, repostedByName, linkedinUrl}, ...]
  14. """
  15. import argparse, json, os, re, subprocess, sys, datetime, tempfile
  16. REL_KEYWORDS = [
  17. "launch", "launching", "introducing", "new product", "now available", "now at",
  18. "hiring", "we're hiring", "join our team", "join us", "open role", "opening",
  19. "opened", "grand opening", "expansion", "expanding", "expand", "new location",
  20. "event", "festival", "conference", "expo", "trade show", "tradeshow", "booth",
  21. "sponsor", "sponsoring", "award", "awarded", "won", "ranked", "honored",
  22. "milestone", "anniversary", "celebrat", "partnership", "partnered", "partner with",
  23. "funding", "raised", "series a", "series b", "series c", "acquisition", "acquire",
  24. "store", "retail", "ribbon", "exhibit", "summit", "kickoff", "unveil", "debut",
  25. ]
  26. def slug_of(url):
  27. m = re.search(r'/company/([^/?]+)', url or "")
  28. return m.group(1).lower() if m else ""
  29. def short_date(iso):
  30. try:
  31. return datetime.datetime.fromisoformat(iso.replace("Z", "+00:00")).strftime("%b %Y")
  32. except Exception:
  33. return (iso or "")[:7]
  34. def one_line(text, limit=190):
  35. t = re.sub(r'\s+', ' ', (text or "").strip())
  36. if len(t) > limit:
  37. cut = t[:limit]
  38. # try to end on sentence/word boundary
  39. for sep in (". ", "! ", "? "):
  40. i = cut.rfind(sep)
  41. if i > 60:
  42. return cut[:i+1].strip()
  43. return cut.rsplit(" ", 1)[0].strip() + "…"
  44. return t
  45. def score(post, company_name):
  46. c = (post.get("content") or "").lower()
  47. s = 0
  48. for kw in REL_KEYWORDS:
  49. if kw in c:
  50. s += 3
  51. # prefer company-authored over reposts / individual profiles
  52. if (post.get("authorType") == "company"):
  53. s += 4
  54. if post.get("type") == "post":
  55. s += 1
  56. if post.get("repostedByName"):
  57. s -= 1
  58. # mild recency tiebreak handled by sort on date
  59. if not c.strip():
  60. s -= 5
  61. return s
  62. def select_posts(posts, company_name, n=3):
  63. ranked = sorted(posts, key=lambda p: (score(p, company_name), p.get("date") or ""), reverse=True)
  64. out = [p for p in ranked if (p.get("content") or "").strip()][:n]
  65. return out
  66. def main():
  67. ap = argparse.ArgumentParser()
  68. ap.add_argument("--batch", required=True)
  69. ap.add_argument("--posts", required=True)
  70. ap.add_argument("--stubdir", required=True)
  71. ap.add_argument("--script", required=True)
  72. ap.add_argument("--date", default=datetime.date.today().isoformat())
  73. args = ap.parse_args()
  74. batch = json.load(open(args.batch, encoding="utf-8"))
  75. raw = json.load(open(args.posts, encoding="utf-8"))
  76. # accept: flat list of normalized dicts, raw harvestapi items, or {items:[...]}
  77. if isinstance(raw, dict) and "items" in raw:
  78. raw = raw["items"]
  79. posts = []
  80. for p in raw:
  81. if "targetUrl" in p: # already normalized
  82. posts.append(p); continue
  83. posts.append({
  84. "targetUrl": (p.get("query") or {}).get("targetUrl", ""),
  85. "content": p.get("content", ""),
  86. "date": (p.get("postedAt") or {}).get("date", ""),
  87. "type": p.get("type", ""),
  88. "authorName": (p.get("author") or {}).get("name", ""),
  89. "authorType": (p.get("author") or {}).get("type", ""),
  90. "repostedByName": (p.get("repostedBy") or {}).get("name", ""),
  91. "linkedinUrl": p.get("linkedinUrl", ""),
  92. })
  93. by_slug = {}
  94. for p in posts:
  95. s = slug_of(p.get("targetUrl"))
  96. if s:
  97. by_slug.setdefault(s, []).append(p)
  98. summary = []
  99. for r in batch:
  100. stub_path = os.path.join(args.stubdir, r["file"])
  101. # Person from existing frontmatter (free, no API)
  102. person = []
  103. if r.get("title"): person.append(f"- Title: {r['title']}")
  104. if r.get("seniority"): person.append(f"- Seniority: {r['seniority']}")
  105. loc = ", ".join([x for x in [r.get("city"), r.get("state")] if x])
  106. if loc: person.append(f"- Location: {loc}")
  107. if r.get("linkedin"): person.append(f"- LinkedIn: {r['linkedin']}")
  108. person = person or ["[TBD]"]
  109. # Company minimal (Apollo skipped this run)
  110. company = []
  111. if r.get("company"): company.append(f"- {r['company']}")
  112. if r.get("company_li"): company.append(f"- LinkedIn: {r['company_li']}")
  113. company.append("- [TBD: firmographics (revenue/headcount/funding) not pulled, Apollo skipped this run]")
  114. # Signals from posts
  115. slug = r.get("slug", "")
  116. chosen = select_posts(by_slug.get(slug, []), r.get("company", ""), n=3) if slug else []
  117. signals, sources = [], []
  118. for p in chosen:
  119. signals.append(f"- {short_date(p.get('date'))}: {one_line(p.get('content'))}")
  120. if p.get("linkedinUrl"):
  121. sources.append(f"- LinkedIn post ({short_date(p.get('date'))}): {p['linkedinUrl']}")
  122. if not signals:
  123. signals = ["[TBD: no recent company LinkedIn posts found in last 3 months]"]
  124. sources.append(f"- Company LinkedIn posts via Apify harvestapi/linkedin-company-posts, {args.date}")
  125. sources.append(f"- Stub frontmatter (prior enrichment)")
  126. payload = {"frontmatter": {}, "person": person, "company": company,
  127. "signals": signals, "sources": sources}
  128. with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as tf:
  129. json.dump(payload, tf)
  130. pjson = tf.name
  131. res = subprocess.run([sys.executable, args.script, "--stub", stub_path,
  132. "--payload", pjson, "--date", args.date],
  133. capture_output=True, text=True)
  134. os.unlink(pjson)
  135. ok = res.returncode == 0
  136. summary.append((r["file"], len([s for s in signals if not s.startswith("[TBD")]), ok,
  137. res.stderr.strip()[:120]))
  138. good = sum(1 for _,_,ok,_ in summary if ok)
  139. withsig = sum(1 for _,n,_,_ in summary if n > 0)
  140. print(f"WROTE {good}/{len(summary)} stubs OK; {withsig} have >=1 real signal.")
  141. print("\nstub | signals | status")
  142. for f, n, ok, err in summary:
  143. print(f" {f[:34]:36} | {n} | {'OK' if ok else 'FAIL: '+err}")
  144. if __name__ == "__main__":
  145. main()