← 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()