← back to Linkedin Voice Agent

scripts/build_profile.py

194 lines

#!/usr/bin/env python3
"""build_profile.py — distill a reusable LinkedIn VOICE PROFILE from your own
past posts. This is the "analyzes your past posts to learn your themes + voice,
style, and tone" half of what Okara's LinkedIn Agent v2 sells — run locally at
$0 instead of paying for it.

INPUTS (pick one):
  --export  <Shares.csv>   LinkedIn "Download your data" export (the honest,
                           TOS-clean, $0 way to read YOUR OWN past posts).
                           Get it at: linkedin.com/mypreferences/d/download-my-data
                           → "Posts" → wait for email → unzip → Shares.csv
  --posts   <file.txt>     Plain text: posts separated by a blank line or a
                           line of "---". Paste-in fallback.

OUTPUT:
  data/profiles/<name>.json   the voice profile the generator consumes.

The style STATS are computed deterministically in Python (free, reproducible);
only the natural-language theme/voice synthesis calls local Ollama.

Usage:
  python3 scripts/build_profile.py --export ~/Downloads/Shares.csv --name steve
  python3 scripts/build_profile.py --posts  ~/Desktop/my_posts.txt  --name steve
"""
import argparse
import csv
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")

EMOJI_RE = re.compile(
    "[\U0001F300-\U0001FAFF\U00002600-\U000027BF\U0001F1E6-\U0001F1FF←-⇿⬀-⯿]"
)


def load_from_csv(path):
    """LinkedIn Shares.csv → list of post texts. Column name has varied over
    the years (ShareCommentary / Commentary / Text), so auto-detect."""
    posts = []
    with open(path, newline="", encoding="utf-8", errors="replace") as f:
        reader = csv.DictReader(f)
        cols = reader.fieldnames or []
        # rank candidate text columns
        cand = None
        for want in ("ShareCommentary", "Commentary", "ShareText", "Text", "content"):
            for c in cols:
                if c and c.strip().lower() == want.lower():
                    cand = c
                    break
            if cand:
                break
        if not cand:
            sys.exit(f"Could not find a post-text column in {os.path.basename(path)}. "
                     f"Columns present: {cols}")
        for row in reader:
            t = (row.get(cand) or "").strip()
            if t:
                posts.append(t)
    return posts


def load_from_txt(path):
    raw = open(path, encoding="utf-8", errors="replace").read()
    # split on --- lines or blank-line gaps of 1+
    chunks = re.split(r"\n\s*(?:-{3,}|\*{3,})\s*\n|\n{2,}", raw)
    return [c.strip() for c in chunks if c.strip()]


def compute_stats(posts):
    n = len(posts)
    def avg(f):
        return round(sum(f(p) for p in posts) / n, 2) if n else 0
    hashtags = [len(re.findall(r"#\w+", p)) for p in posts]
    return {
        "n_posts": n,
        "avg_chars": avg(len),
        "avg_lines": avg(lambda p: p.count("\n") + 1),
        "avg_words": avg(lambda p: len(p.split())),
        "pct_with_question": round(100 * sum(1 for p in posts if "?" in p) / n) if n else 0,
        "pct_with_emoji": round(100 * sum(1 for p in posts if EMOJI_RE.search(p)) / n) if n else 0,
        "pct_with_hashtag": round(100 * sum(1 for h in hashtags if h) / n) if n else 0,
        "avg_hashtags": round(sum(hashtags) / n, 2) if n else 0,
        "pct_with_list": round(100 * sum(1 for p in posts if re.search(r"(?m)^\s*(?:[-•*\d]|\d[.)])", p)) / n) if n else 0,
    }


def top_hooks(posts, k=12):
    """First line of the longest / most-engaging-looking posts = hook exemplars."""
    ranked = sorted(posts, key=len, reverse=True)
    hooks, seen = [], set()
    for p in ranked:
        first = p.strip().splitlines()[0].strip()
        if 8 <= len(first) <= 140 and first.lower() not in seen:
            hooks.append(first)
            seen.add(first.lower())
        if len(hooks) >= k:
            break
    return hooks


def synthesize(posts, stats, name):
    sample = "\n\n---\n\n".join(p[:600] for p in posts[:25])
    prompt = f"""You are a brand-voice analyst. Below are real LinkedIn posts written by {name}.
Study them and return ONLY a JSON object (no prose) with these keys:

  "core_themes":     array of 4-8 short topic labels this person posts about
  "voice_summary":   2-4 sentences describing their voice, tone, and register
                     (e.g. warm/direct, plain-spoken, wry, expert, first-person)
                     — specific enough to imitate, grounded in the samples.
  "signature_moves": array of 3-6 concrete recurring stylistic habits
                     (e.g. "opens with a one-line contrarian hook",
                      "uses short 1-2 line paragraphs with whitespace",
                      "ends with a soft question", "no hashtags").
  "avoid":           array of 2-5 things that would break the voice
                     (things they never do — corporate buzzwords, hashtag spam, etc.)

Measured style stats (respect these when describing habits): {json.dumps(stats)}

POSTS:
{sample}

Return the JSON object only."""
    try:
        raw, model = _ollama.ask(prompt, temperature=0.4, num_predict=900)
        data = _ollama.extract_json(raw)
        if isinstance(data, dict) and data.get("voice_summary"):
            data["_voice_source"] = f"ollama:{model} (local, $0)"
            return data
    except Exception as e:
        print(f"  (ollama synthesis failed: {e} — falling back to stats-only)", file=sys.stderr)
    # graceful fallback: build a stats-derived profile so the tool still works
    return {
        "core_themes": [],
        "voice_summary": (f"{name} writes ~{int(stats['avg_words'])}-word LinkedIn posts, "
                          f"{'often asking a question' if stats['pct_with_question'] > 40 else 'mostly declarative'}, "
                          f"{'emoji-friendly' if stats['pct_with_emoji'] > 40 else 'light on emoji'}, "
                          f"{'hashtag-light' if stats['avg_hashtags'] < 2 else 'hashtag-forward'}."),
        "signature_moves": [],
        "avoid": [],
        "_voice_source": "stats-only (ollama unavailable)",
    }


def main():
    ap = argparse.ArgumentParser()
    src = ap.add_mutually_exclusive_group(required=True)
    src.add_argument("--export", help="LinkedIn Shares.csv from Download-your-data")
    src.add_argument("--posts", help="plain .txt, posts split by blank line or ---")
    ap.add_argument("--name", required=True, help="profile name, e.g. steve")
    ap.add_argument("--max", type=int, default=200, help="cap posts analysed")
    args = ap.parse_args()

    posts = load_from_csv(args.export) if args.export else load_from_txt(args.posts)
    if not posts:
        sys.exit("No posts found in the input.")
    posts = posts[: args.max]
    print(f"Loaded {len(posts)} posts for '{args.name}'.")

    stats = compute_stats(posts)
    print("Computing voice profile via local Ollama ($0)...")
    synth = synthesize(posts, stats, args.name)

    profile = {
        "name": args.name,
        "source": os.path.basename(args.export or args.posts),
        "built_at": datetime.datetime.now().isoformat(timespec="seconds"),
        "stats": stats,
        "sample_hooks": top_hooks(posts),
        **synth,
    }
    os.makedirs(PROFILE_DIR, exist_ok=True)
    out = os.path.join(PROFILE_DIR, f"{args.name}.json")
    with open(out, "w") as f:
        json.dump(profile, f, indent=2, ensure_ascii=False)

    print(f"\n✅ Voice profile → {out}")
    print(f"   themes: {', '.join(profile.get('core_themes') or ['(none synthesised)'])}")
    print(f"   voice : {profile['voice_summary']}")
    print(f"   source: {profile['_voice_source']}")
    print(f"\nNext:  python3 scripts/generate.py --profile {args.name} --count 3")


if __name__ == "__main__":
    main()