← back to Linkedin Voice Agent
scripts/generate.py
189 lines
#!/usr/bin/env python3
"""generate.py — write new LinkedIn posts in a captured voice. This is the
"writes new LinkedIn posts for you every day" half of Okara's LinkedIn Agent v2,
run locally at $0.
Reads a voice profile built by build_profile.py, generates N post drafts (in
that voice, constrained by the measured style + a topic/news angle), scores each
draft's hook strength deterministically, sorts strongest-first, and writes a
human-review queue.
HARD RAIL — DRAFT ONLY. This never posts to LinkedIn. Automated 3rd-party
posting violates LinkedIn's TOS, and outward publishing is Steve-gated anyway.
To publish an approved draft, hand it to the `linkedin-api` skill (Steve's go).
Usage:
python3 scripts/generate.py --profile steve --count 3
python3 scripts/generate.py --profile steve --topic "AI in interior design"
python3 scripts/generate.py --profile steve --news "headline or paragraph to react to"
"""
import argparse
import datetime
import json
import os
import re
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import _ollama # noqa: E402
ROOT = os.path.dirname(HERE)
PROFILE_DIR = os.path.join(ROOT, "data", "profiles")
DRAFT_DIR = os.path.join(ROOT, "data", "drafts")
POWER_WORDS = {"how", "why", "what", "stop", "start", "never", "always", "most",
"nobody", "everyone", "truth", "mistake", "secret", "lesson",
"here's", "unpopular", "hot take", "i was wrong", "the hard way"}
def load_profile(name):
p = os.path.join(PROFILE_DIR, f"{name}.json")
if not os.path.exists(p):
sys.exit(f"No profile '{name}'. Build one first:\n"
f" python3 scripts/build_profile.py --export <Shares.csv> --name {name}")
return json.load(open(p))
def hook_score(text):
"""Deterministic 0-100 hook-strength heuristic on the FIRST line.
Reproducible and free — no model call. Rewards curiosity + specificity."""
first = text.strip().splitlines()[0].strip()
s, low = 0, first.lower()
n = len(first)
# length sweet-spot for a scroll-stopping first line (~30-90 chars)
if 25 <= n <= 90:
s += 30
elif n < 25:
s += 12
elif n <= 130:
s += 18
if "?" in first:
s += 12
if re.search(r"\d", first):
s += 14 # a concrete number
if any(w in low for w in POWER_WORDS):
s += 16
if first[:1].isupper() and not first.isupper():
s += 6 # cased, not shouty
if first.count(" ") <= 14:
s += 8 # tight, not a run-on
if "#" in first:
s -= 10 # hashtag in the hook = weak
if low.startswith(("i am excited", "i'm excited", "excited to announce")):
s -= 12 # the cliché LinkedIn opener
return max(0, min(100, s))
def build_prompt(profile, count, topic, news):
st = profile["stats"]
themes = ", ".join(profile.get("core_themes") or ["(their usual topics)"])
moves = "\n".join(f" - {m}" for m in (profile.get("signature_moves") or []))
avoid = "\n".join(f" - {a}" for a in (profile.get("avoid") or []))
hooks = "\n".join(f" - {h}" for h in (profile.get("sample_hooks") or [])[:8])
if news:
brief = f'React to / build on this in the author\'s own voice:\n"""{news}"""'
elif topic:
brief = f'Write about this topic: "{topic}".'
else:
brief = ("Write about the author's own core themes. Pick fresh, specific "
"angles — no generic advice.")
return f"""You ghostwrite LinkedIn posts in ONE specific person's voice. Imitate them exactly.
VOICE OF {profile['name'].upper()}:
{profile['voice_summary']}
CORE THEMES: {themes}
SIGNATURE MOVES (do these):
{moves or ' - short paragraphs, first-person, plain language'}
NEVER DO:
{avoid or ' - corporate buzzwords, hashtag spam, humble-brag openers'}
MEASURED STYLE (match it): ~{int(st['avg_words'])} words, ~{int(st['avg_lines'])} lines,
avg {st['avg_hashtags']} hashtags, question in ~{st['pct_with_question']}% of posts,
emoji in ~{st['pct_with_emoji']}% of posts.
EXAMPLES OF THEIR REAL HOOKS (match this energy, don't copy):
{hooks or ' - (none captured)'}
TASK: {brief}
Write {count} DISTINCT LinkedIn posts. Each: a scroll-stopping first line, then
the body with whitespace between short paragraphs. Native LinkedIn — no markdown
headers, no "**bold**". Sound like a human, not a brand.
Return ONLY a JSON array of objects, each:
{{"angle": "<2-4 word description of the angle>", "text": "<the full post>"}}"""
def generate(profile, count, topic, news):
prompt = build_prompt(profile, count, topic, news)
raw, model = _ollama.ask(prompt, temperature=0.9, num_predict=1800)
data = _ollama.extract_json(raw)
drafts = []
if isinstance(data, list):
for d in data:
if isinstance(d, dict) and d.get("text"):
drafts.append({"angle": d.get("angle", ""), "text": d["text"].strip()})
if not drafts:
# last-ditch: treat the whole reply as one post so the run isn't a dead end
drafts = [{"angle": "raw", "text": raw.strip()}]
for d in drafts:
d["hook_score"] = hook_score(d["text"])
d["chars"] = len(d["text"])
drafts.sort(key=lambda d: d["hook_score"], reverse=True)
return drafts, model
def write_queue(profile, drafts, model, topic, news):
os.makedirs(DRAFT_DIR, exist_ok=True)
stamp = datetime.datetime.now().strftime("%Y-%m-%d_%H%M")
base = os.path.join(DRAFT_DIR, f"{profile['name']}-{stamp}")
# jsonl for machines
with open(base + ".jsonl", "w") as f:
for d in drafts:
f.write(json.dumps(d, ensure_ascii=False) + "\n")
# markdown for the human review
ctx = news or topic or "core themes"
lines = [f"# LinkedIn drafts — {profile['name']} ({stamp})",
f"_brief: {ctx} · model: {model} (local, $0) · DRAFT ONLY — not posted_\n"]
for i, d in enumerate(drafts, 1):
lines.append(f"## {i}. [{d['hook_score']}/100 hook] {d['angle']} · {d['chars']} chars\n")
lines.append(d["text"])
lines.append("\n---\n")
lines.append("To publish an approved draft (Steve's go): hand its text to the "
"`linkedin-api` skill →\n"
"`python3 ~/.claude/skills/linkedin-api/scripts/post.py --text \"<draft>\"`")
md = base + ".md"
with open(md, "w") as f:
f.write("\n".join(lines))
return md
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--profile", required=True, help="voice profile name")
ap.add_argument("--count", type=int, default=3)
ap.add_argument("--topic", help="topic to write about")
ap.add_argument("--news", help="a headline/paragraph to react to")
args = ap.parse_args()
profile = load_profile(args.profile)
print(f"Generating {args.count} drafts in {args.profile}'s voice (local, $0)...")
drafts, model = generate(profile, args.count, args.topic, args.news)
md = write_queue(profile, drafts, model, args.topic, args.news)
print(f"\n✅ {len(drafts)} drafts → {md}")
for i, d in enumerate(drafts, 1):
first = d["text"].splitlines()[0][:80]
print(f" {i}. [{d['hook_score']}/100] {first}")
print("\nDRAFT ONLY — review the .md, then publish an approved one via the "
"linkedin-api skill (Steve-gated).")
if __name__ == "__main__":
main()