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

patch_research.py

Language Python5.2 KB163 lines

skills/client-enrich/scripts/patch_research.py

  1. #!/usr/bin/env python3
  2. """
  3. patch_research.py: surgically patch individual ## Research subsections of a stub,
  4. preserving the ones you don't touch. Complements enrich_writeback.py (which rebuilds
  5. the whole block). Honors the contract: only ## Research + frontmatter change; never
  6. Human Notes.
  7. Patch JSON (all optional):
  8. "frontmatter": {k:v} fill EMPTY/[TBD] frontmatter keys only (unless --force)
  9. "person": [lines] REPLACE the Person / LinkedIn subsection
  10. "company": [lines] REPLACE the Company subsection
  11. "signals": [lines] REPLACE the Recent News & Signals subsection
  12. "signals_append": [lines] APPEND to signals (removes a lone [TBD] placeholder first)
  13. "sources_append": [lines] APPEND to Sources, skipping lines already there
  14. Subsections not named are left byte-for-byte intact.
  15. Usage: python3 patch_research.py --stub PATH --patch patch.json [--force] [--date YYYY-MM-DD]
  16. """
  17. import argparse
  18. import datetime
  19. import json
  20. import re
  21. import sys
  22. from _stub import fm_set, split_frontmatter
  23. HEADINGS = [
  24. "### Company",
  25. "### Person / LinkedIn",
  26. "### Recent News & Signals",
  27. "### Sources",
  28. ]
  29. KEYMAP = {
  30. "### Company": "company",
  31. "### Person / LinkedIn": "person",
  32. "### Recent News & Signals": "signals",
  33. "### Sources": "sources",
  34. }
  35. FM_KEYS = [
  36. "title",
  37. "seniority",
  38. "city",
  39. "state",
  40. "linkedin",
  41. "company",
  42. "company_linkedin",
  43. "industry",
  44. "company_website",
  45. ]
  46. def parse_research(body):
  47. m = re.search(r"(?m)^## Research\s*$", body)
  48. if not m:
  49. sys.exit("ERROR: no ## Research")
  50. start = m.start()
  51. nxt = re.search(r"(?m)^## (?!Research).*$", body[start + 1 :])
  52. if not nxt:
  53. sys.exit("ERROR: no section after Research")
  54. end = start + 1 + nxt.start()
  55. block = body[start:end]
  56. # comment line right after ## Research (optional)
  57. lines = block.splitlines()
  58. comment = ""
  59. idx = 1
  60. if idx < len(lines) and lines[idx].strip().startswith("<!--"):
  61. comment = lines[idx]
  62. idx += 1
  63. subs = {}
  64. cur = None
  65. for l in lines[idx:]:
  66. if l.strip() in HEADINGS:
  67. cur = l.strip()
  68. subs[cur] = []
  69. elif cur is not None:
  70. subs[cur].append(l)
  71. # trim trailing blank lines per sub
  72. for k in subs:
  73. while subs[k] and subs[k][-1].strip() == "":
  74. subs[k].pop()
  75. return start, end, comment, subs
  76. def main():
  77. ap = argparse.ArgumentParser()
  78. ap.add_argument("--stub", required=True)
  79. ap.add_argument("--patch", required=True)
  80. ap.add_argument("--force", action="store_true")
  81. ap.add_argument("--date", default=datetime.date.today().isoformat())
  82. a = ap.parse_args()
  83. text = open(a.stub, encoding="utf-8").read()
  84. patch = json.load(open(a.patch, encoding="utf-8"))
  85. fm, body = split_frontmatter(text)
  86. human = body.split("## Human Notes", 1)
  87. human_snap = ("## Human Notes" + human[1]) if len(human) > 1 else None
  88. changed = []
  89. for k, v in (patch.get("frontmatter") or {}).items():
  90. if k in FM_KEYS and str(v).strip():
  91. fm, did = fm_set(fm, k, str(v).strip(), a.force, literal=False)
  92. if did:
  93. changed.append(k)
  94. # Stamp the file's existing date key (vault stubs use last_modified); never add a second.
  95. for _k in ("last_modified", "updated"):
  96. if re.search(rf"(?m)^{_k}:", fm):
  97. fm = re.sub(rf"(?m)^{_k}:.*$", f"{_k}: '{a.date}'", fm, count=1)
  98. break
  99. start, end, comment, subs = parse_research(body)
  100. def setsub(h, lines):
  101. subs[h] = list(lines)
  102. if "person" in patch:
  103. setsub("### Person / LinkedIn", patch["person"])
  104. if "company" in patch:
  105. setsub("### Company", patch["company"])
  106. if "signals" in patch:
  107. setsub("### Recent News & Signals", patch["signals"])
  108. if "signals_append" in patch:
  109. cur = [
  110. l
  111. for l in subs.get("### Recent News & Signals", [])
  112. if "[TBD" not in l and l.strip()
  113. ]
  114. setsub("### Recent News & Signals", cur + patch["signals_append"])
  115. if "sources_append" in patch:
  116. cur = subs.get("### Sources", [])
  117. have = set(x.strip() for x in cur)
  118. setsub(
  119. "### Sources",
  120. cur + [s for s in patch["sources_append"] if s.strip() not in have],
  121. )
  122. out = ["## Research"]
  123. if comment:
  124. out.append(comment)
  125. for h in HEADINGS:
  126. out.append(h)
  127. ls = subs.get(h, [])
  128. out.extend(ls if ls else (["[TBD]"] if h != "### Sources" else []))
  129. newblock = "\n".join(out) + "\n"
  130. body = body[:start] + newblock + "\n" + body[end:]
  131. if human_snap is not None:
  132. after = body.split("## Human Notes", 1)
  133. if ("## Human Notes" + after[1] if len(after) > 1 else None) != human_snap:
  134. sys.exit("ABORT: Human Notes would change")
  135. open(a.stub, "w", encoding="utf-8").write(f"---\n{fm}\n---\n{body}")
  136. touched = set(patch)
  137. if "signals_append" in patch:
  138. touched.add("signals")
  139. if "sources_append" in patch:
  140. touched.add("sources")
  141. patched = ",".join(KEYMAP[h] for h in HEADINGS if KEYMAP[h] in touched)
  142. print(
  143. f"OK {a.stub.split('/')[-1]} | fm:{','.join(changed) or '-'} | patched:{patched}"
  144. )
  145. if __name__ == "__main__":
  146. main()