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

enrich_writeback.py

Language Python5.6 KB150 lines

skills/client-enrich/scripts/enrich_writeback.py

  1. #!/usr/bin/env python3
  2. """
  3. enrich_writeback.py: deterministic write-back for the client-enrich skill.
  4. Honors the quarterly-outreach scraping-dump contract:
  5. - writes ONLY under `## Research` (its four subsections) + frontmatter
  6. - NEVER touches `## Human Notes`, the title line, Order History, Outreach Log, Links
  7. - updates frontmatter keys only if currently empty or "[TBD]" (unless --force)
  8. - always bumps the stub's existing date key (`last_modified`, else `updated`)
  9. - leaves the rest of the file byte-identical
  10. Usage:
  11. python3 enrich_writeback.py --stub PATH --payload payload.json [--force] [--date YYYY-MM-DD]
  12. payload.json schema (all keys optional; omit what you don't have):
  13. {
  14. "frontmatter": {
  15. "title": "Head of Events & Brand Marketing",
  16. "seniority": "director",
  17. "city": "Denver",
  18. "state": "Colorado",
  19. "linkedin": "https://www.linkedin.com/in/example-jordan-reyes",
  20. "company": "Brightwater Provisions",
  21. "company_linkedin": "https://www.linkedin.com/company/example-brightwater-provisions"
  22. },
  23. "person": ["- Headline: ...", "- Current: Head of Events ... at Brightwater Provisions (since 2024-09)", "..."],
  24. "company": ["- Industry: consumer goods", "- Employees: ~60", "..."],
  25. "signals": ["- Headcount up ~25% over 12 months", "..."],
  26. "sources": ["Apollo enrichment (people-match), 2026-06-16", "https://..."]
  27. }
  28. person/company/signals/sources are lists of pre-formatted markdown lines
  29. (strings). Bullets ("- ") are written as-is; the script does not reformat them.
  30. """
  31. import argparse, json, re, sys, datetime
  32. from _stub import fm_set, split_frontmatter
  33. SUBSECTIONS = [
  34. ("person", "### Person / LinkedIn"),
  35. ("company", "### Company"),
  36. ("signals", "### Recent News & Signals"),
  37. ("sources", "### Sources"),
  38. ]
  39. # canonical order in the stub template
  40. RESEARCH_ORDER = ["### Company", "### Person / LinkedIn",
  41. "### Recent News & Signals", "### Sources"]
  42. KEY_BY_HEADING = {h: k for k, h in SUBSECTIONS}
  43. FM_KEYS = ["title", "seniority", "city", "state", "linkedin",
  44. "industry", "company_website",
  45. "company", "company_linkedin"]
  46. def load(path):
  47. with open(path, "r", encoding="utf-8") as f:
  48. return f.read()
  49. DATE_KEYS = ("last_modified", "updated") # vault stubs use last_modified
  50. def bump_updated(fm, date):
  51. """Stamp the file's existing date key. Never introduce a second one.
  52. Vault stubs (and everything the weekly driver writes) carry `last_modified`;
  53. older hand-made files may carry `updated`. Writing a fixed `updated:` key
  54. would add a second date key that then drifts from the first, so stamp
  55. whichever key is present, preferring last_modified; add one only if neither is.
  56. """
  57. for key in DATE_KEYS:
  58. if re.search(rf'(?m)^{key}:[ \t]*.*$', fm):
  59. return re.sub(rf'(?m)^{key}:[ \t]*.*$', f"{key}: '{date}'", fm, count=1)
  60. return fm.rstrip() + f"\nlast_modified: '{date}'"
  61. def build_research_block(payload, date):
  62. out = ["## Research",
  63. f"<!-- Enriched {date} via client-enrich skill. Sourced below. Never overwrite Human Notes. -->"]
  64. for heading in RESEARCH_ORDER:
  65. key = KEY_BY_HEADING[heading]
  66. out.append(heading)
  67. lines = payload.get(key) or []
  68. if lines:
  69. out.extend(lines)
  70. else:
  71. out.append("[TBD]")
  72. return "\n".join(out) + "\n"
  73. def replace_research(body, new_block):
  74. # Research starts at "## Research" and ends just before the next "## " heading
  75. m = re.search(r'(?m)^## Research\s*$', body)
  76. if not m:
  77. sys.exit("ERROR: no '## Research' section found in stub.")
  78. start = m.start()
  79. nxt = re.search(r'(?m)^## (?!Research).*$', body[start + 1:])
  80. if not nxt:
  81. sys.exit("ERROR: no section after '## Research' (expected '## Human Notes').")
  82. end = start + 1 + nxt.start()
  83. return body[:start] + new_block + "\n" + body[end:]
  84. def main():
  85. ap = argparse.ArgumentParser()
  86. ap.add_argument("--stub", required=True)
  87. ap.add_argument("--payload", required=True)
  88. ap.add_argument("--force", action="store_true",
  89. help="overwrite non-empty frontmatter values too")
  90. ap.add_argument("--date", default=datetime.date.today().isoformat())
  91. args = ap.parse_args()
  92. text = load(args.stub)
  93. with open(args.payload, "r", encoding="utf-8") as f:
  94. payload = json.load(f)
  95. fm, body = split_frontmatter(text)
  96. before_human = body.split("## Human Notes", 1)
  97. human_snapshot = ("## Human Notes" + before_human[1]) if len(before_human) > 1 else None
  98. changed = []
  99. for key in FM_KEYS:
  100. if key in payload.get("frontmatter", {}):
  101. val = str(payload["frontmatter"][key]).strip()
  102. if val:
  103. fm, did = fm_set(fm, key, val, args.force)
  104. if did:
  105. changed.append(key)
  106. fm = bump_updated(fm, args.date)
  107. body = replace_research(body, build_research_block(payload, args.date))
  108. # guard: Human Notes must be untouched
  109. if human_snapshot is not None:
  110. after = body.split("## Human Notes", 1)
  111. after_human = ("## Human Notes" + after[1]) if len(after) > 1 else None
  112. if after_human != human_snapshot:
  113. sys.exit("ABORT: Human Notes section would change; refusing to write.")
  114. out = f"---\n{fm}\n---\n{body}"
  115. with open(args.stub, "w", encoding="utf-8") as f:
  116. f.write(out)
  117. print(f"OK {args.stub}")
  118. print(f" frontmatter updated: {', '.join(changed) if changed else '(none - already filled)'}")
  119. print(f" research subsections written: "
  120. f"{', '.join(k for k,_ in SUBSECTIONS if payload.get(k))}")
  121. if __name__ == "__main__":
  122. main()