patch_research.py
Language Python5.2 KB163 lines
#!/usr/bin/env python3"""patch_research.py: surgically patch individual ## Research subsections of a stub,preserving the ones you don't touch. Complements enrich_writeback.py (which rebuildsthe whole block). Honors the contract: only ## Research + frontmatter change; neverHuman Notes.Patch JSON (all optional):"frontmatter": {k:v} fill EMPTY/[TBD] frontmatter keys only (unless --force)"person": [lines] REPLACE the Person / LinkedIn subsection"company": [lines] REPLACE the Company subsection"signals": [lines] REPLACE the Recent News & Signals subsection"signals_append": [lines] APPEND to signals (removes a lone [TBD] placeholder first)"sources_append": [lines] APPEND to Sources, skipping lines already thereSubsections not named are left byte-for-byte intact.Usage: python3 patch_research.py --stub PATH --patch patch.json [--force] [--date YYYY-MM-DD]"""import argparseimport datetimeimport jsonimport reimport sysfrom _stub import fm_set, split_frontmatterHEADINGS = ["### Company","### Person / LinkedIn","### Recent News & Signals","### Sources",]KEYMAP = {"### Company": "company","### Person / LinkedIn": "person","### Recent News & Signals": "signals","### Sources": "sources",}FM_KEYS = ["title","seniority","city","state","linkedin","company","company_linkedin","industry","company_website",]def parse_research(body):m = re.search(r"(?m)^## Research\s*$", body)if not m:sys.exit("ERROR: no ## Research")start = m.start()nxt = re.search(r"(?m)^## (?!Research).*$", body[start + 1 :])if not nxt:sys.exit("ERROR: no section after Research")end = start + 1 + nxt.start()block = body[start:end]# comment line right after ## Research (optional)lines = block.splitlines()comment = ""idx = 1if idx < len(lines) and lines[idx].strip().startswith("<!--"):comment = lines[idx]idx += 1subs = {}cur = Nonefor l in lines[idx:]:if l.strip() in HEADINGS:cur = l.strip()subs[cur] = []elif cur is not None:subs[cur].append(l)# trim trailing blank lines per subfor k in subs:while subs[k] and subs[k][-1].strip() == "":subs[k].pop()return start, end, comment, subsdef main():ap = argparse.ArgumentParser()ap.add_argument("--stub", required=True)ap.add_argument("--patch", required=True)ap.add_argument("--force", action="store_true")ap.add_argument("--date", default=datetime.date.today().isoformat())a = ap.parse_args()text = open(a.stub, encoding="utf-8").read()patch = json.load(open(a.patch, encoding="utf-8"))fm, body = split_frontmatter(text)human = body.split("## Human Notes", 1)human_snap = ("## Human Notes" + human[1]) if len(human) > 1 else Nonechanged = []for k, v in (patch.get("frontmatter") or {}).items():if k in FM_KEYS and str(v).strip():fm, did = fm_set(fm, k, str(v).strip(), a.force, literal=False)if did:changed.append(k)# Stamp the file's existing date key (vault stubs use last_modified); never add a second.for _k in ("last_modified", "updated"):if re.search(rf"(?m)^{_k}:", fm):fm = re.sub(rf"(?m)^{_k}:.*$", f"{_k}: '{a.date}'", fm, count=1)breakstart, end, comment, subs = parse_research(body)def setsub(h, lines):subs[h] = list(lines)if "person" in patch:setsub("### Person / LinkedIn", patch["person"])if "company" in patch:setsub("### Company", patch["company"])if "signals" in patch:setsub("### Recent News & Signals", patch["signals"])if "signals_append" in patch:cur = [lfor l in subs.get("### Recent News & Signals", [])if "[TBD" not in l and l.strip()]setsub("### Recent News & Signals", cur + patch["signals_append"])if "sources_append" in patch:cur = subs.get("### Sources", [])have = set(x.strip() for x in cur)setsub("### Sources",cur + [s for s in patch["sources_append"] if s.strip() not in have],)out = ["## Research"]if comment:out.append(comment)for h in HEADINGS:out.append(h)ls = subs.get(h, [])out.extend(ls if ls else (["[TBD]"] if h != "### Sources" else []))newblock = "\n".join(out) + "\n"body = body[:start] + newblock + "\n" + body[end:]if human_snap is not None:after = body.split("## Human Notes", 1)if ("## Human Notes" + after[1] if len(after) > 1 else None) != human_snap:sys.exit("ABORT: Human Notes would change")open(a.stub, "w", encoding="utf-8").write(f"---\n{fm}\n---\n{body}")touched = set(patch)if "signals_append" in patch:touched.add("signals")if "sources_append" in patch:touched.add("sources")patched = ",".join(KEYMAP[h] for h in HEADINGS if KEYMAP[h] in touched)print(f"OK {a.stub.split('/')[-1]} | fm:{','.join(changed) or '-'} | patched:{patched}")if __name__ == "__main__":main()