ai-marketing-skills/content-ops/scripts/content-transform.py
Alfred Claw a96d0d8889 Initial commit: 6 AI marketing skill categories
- growth-engine: Autonomous experiment engine (Karpathy autoresearch for marketing)
- sales-pipeline: RB2B router, deal resurrector, trigger prospector, ICP learner
- content-ops: Expert panel, quality gate, editorial brain, quote miner
- outbound-engine: Cold outbound optimizer, lead pipeline, competitive monitor
- seo-ops: Content attack briefs, GSC optimizer, trend scout
- finance-ops: CFO briefing, cost estimate, scenario modeler

79 files, all sanitized - zero hardcoded credentials or internal references.
2026-03-27 20:14:52 -07:00

745 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
Content Transform — Repurpose long-form content into platform-native drafts.
Reads content atoms, generates platform-native drafts using Claude API + optional
expert panel quality gate. Supports X threads/posts, LinkedIn, YouTube Shorts, and
newsletter formats.
LLM mode is DEFAULT. Use --template-only for fast template-based drafts (no API needed).
Usage:
python content-transform.py --atoms atoms.json --top-n 10
python content-transform.py --atoms atoms.json --template-only
python content-transform.py --atoms atoms.json --no-expert-panel
"""
import json
import uuid
import argparse
import os
import re
import sys
import textwrap
from datetime import datetime, timezone
from pathlib import Path
# ── Configuration ──
SCRIPT_DIR = Path(__file__).resolve().parent
PROJECT_DIR = SCRIPT_DIR.parent
DATA_DIR = Path(os.environ.get("CONTENT_OPS_DATA_DIR", PROJECT_DIR / "data"))
SKILL_DIR = PROJECT_DIR
ATOMS_FILE = DATA_DIR / "content-atoms-latest.json"
# Voice configuration files (optional, for LLM mode)
VOICE_CONFIG_FILE = os.environ.get("VOICE_CONFIG_FILE", str(PROJECT_DIR / "config" / "voice.md"))
STYLE_GUIDE_FILE = os.environ.get("STYLE_GUIDE_FILE", str(PROJECT_DIR / "config" / "style-guide.md"))
PLATFORM_MAP = {
"x": ["x_thread", "x_post"],
"linkedin": ["linkedin_post"],
"short_form": ["youtube_short_script"],
"newsletter": ["newsletter_section"],
"youtube_short": ["youtube_short_script"],
}
MISSING_TO_FORMAT = {
"x": "x_thread",
"linkedin": "linkedin_post",
"short_form": "youtube_short_script",
"newsletter": "newsletter_section",
"youtube_short": "youtube_short_script",
}
MISSING_TO_PLATFORM = {
"x": "x",
"linkedin": "linkedin",
"short_form": "youtube_short",
"newsletter": "newsletter",
"youtube_short": "youtube_short",
}
PLATFORM_TO_EXPERT = {
"x": "x-articles.md",
"linkedin": "linkedin.md",
"youtube_short": "youtube-shorts.md",
"newsletter": "newsletter.md",
}
EXPERT_PANEL_THRESHOLD = 95
EXPERT_PANEL_MAX_ITERATIONS = 3
def load_atoms(path=None):
p = Path(path) if path else ATOMS_FILE
with open(p) as f:
data = json.load(f)
return data.get("atoms", data) if isinstance(data, dict) else data
def rank_atoms(atoms, top_n=10):
"""Sort by repurpose_score * len(platforms_missing), take top N."""
for a in atoms:
a["_rank"] = a.get("repurpose_score", 0) * max(len(a.get("platforms_missing", [])), 1)
ranked = sorted(atoms, key=lambda x: x["_rank"], reverse=True)
return ranked[:top_n]
def clean_content(content):
content = re.sub(r'^[\w]+\s*·\s*@[\w]+\s*·.*$', '', content, flags=re.MULTILINE)
content = re.sub(r'\n{3,}', '\n\n', content)
return content.strip()
def extract_hook(content, max_chars=200):
content = clean_content(content)
for sep in [". ", ".\n", "\n"]:
idx = content.find(sep)
if 0 < idx < max_chars:
return content[:idx + 1].strip()
return content[:max_chars].strip()
def extract_key_points(content, max_points=6):
lines = content.split("\n")
points = []
for line in lines:
line = line.strip()
if not line:
continue
if line.startswith(("", "-", "", "*")) or re.match(r"^\d+[\.\)]", line):
cleaned = re.sub(r"^[•\-→\*\d+\.\)]+\s*", "", line).strip()
if len(cleaned) > 15:
points.append(cleaned)
elif len(line) > 20 and len(line) < 280:
points.append(line)
return points[:max_points] if points else [content[:200]]
def extract_numbers(content):
patterns = [
r'\$[\d,]+[KkMmBb]?(?:\+)?',
r'\d+%',
r'\d+x',
r'\d+[\.,]?\d*\s*(?:hours?|minutes?|days?|weeks?|months?|years?)',
r'\d+\s*(?:pages?|pieces?|tools?|agents?|companies|founders?|members)',
]
numbers = []
for p in patterns:
numbers.extend(re.findall(p, content, re.IGNORECASE))
return numbers[:5]
def shorten_sentence(s, max_words=15):
words = s.split()
if len(words) <= max_words:
return s
return " ".join(words[:max_words]) + "."
def make_punchy(text, max_words=15):
sentences = re.split(r'(?<=[.!?])\s+', text)
result = []
for s in sentences:
s = s.strip()
if not s:
continue
if len(s.split()) > max_words:
parts = re.split(r'[,;—]', s)
for p in parts:
p = p.strip()
if p:
result.append(shorten_sentence(p) if not p.endswith(('.', '!', '?')) else p)
else:
result.append(s)
return result
# ── TEMPLATE GENERATORS (used with --template-only) ──
def generate_x_thread(atom):
content = clean_content(atom["content"])
hook_text = extract_hook(content, 200)
points = extract_key_points(content)
numbers = extract_numbers(content)
tags = atom.get("tags", [])
atom_type = atom.get("atom_type", "")
if "data" in atom_type or numbers:
tweet1 = f"{hook_text}\n\nThe numbers tell a different story. 🧵"
elif "story" in atom_type or "anecdote" in atom_type:
tweet1 = f"{hook_text}\n\nHere's what happened next. 🧵"
else:
tweet1 = f"Most people get this wrong about {tags[0] if tags else 'this'}.\n\n{hook_text}"
if len(tweet1) > 280:
tweet1 = tweet1[:277] + "..."
tweets = [tweet1]
for i, point in enumerate(points[:5]):
point_short = shorten_sentence(point, 15)
if numbers and i < len(numbers):
tweet = f"{point_short}\n\n{numbers[i]} — that's the real number."
else:
tweet = point_short
if len(tweet) > 280:
tweet = tweet[:277] + "..."
tweets.append(tweet)
ctas = [
"What's your take? Reply with what you'd add.",
"What did I miss? Drop your thoughts below.",
"Agree or disagree? I want to hear your take.",
]
tweets.append(ctas[hash(atom["id"]) % len(ctas)])
while len(tweets) < 5:
tweets.insert(-1, "The gap is only getting wider. Those who move now win.")
thread = "\n\n---\n\n".join([f"🧵 {i+1}/{len(tweets)}\n{t}" for i, t in enumerate(tweets)])
return thread, tweets[0]
def generate_x_post(atom):
content = clean_content(atom["content"])
hook = extract_hook(content, 180)
numbers = extract_numbers(content)
num_str = f"\n\n{numbers[0]}." if numbers else ""
post = f"{hook}{num_str}\n\nWhat's your take?"
if len(post) > 280:
post = post[:277] + "..."
return post, hook
def generate_linkedin_post(atom):
content = clean_content(atom["content"])
hook = extract_hook(content, 150)
points = extract_key_points(content)
numbers = extract_numbers(content)
hook_section = f"{hook}\n\nHere's what I learned."
punchy = make_punchy(content)
story = "\n\n".join(punchy[:6])
point_section = "\n".join([f"{p}" for p in points[:4]]) if len(points) > 2 else ""
data_section = f"\nThe data: {', '.join(numbers[:3])}." if numbers else ""
ctas = [
"What would you do differently?",
"What's your experience with this?",
"Curious — what's your take?",
]
cta = ctas[hash(atom["id"]) % len(ctas)]
parts = [hook_section, story]
if point_section:
parts.append(point_section)
if data_section:
parts.append(data_section)
parts.append(cta)
post = "\n\n".join(parts)
if len(post) > 1500:
post = post[:1497] + "..."
return post, hook
def generate_youtube_short(atom):
content = clean_content(atom["content"])
hook = extract_hook(content, 100)
points = extract_key_points(content)
numbers = extract_numbers(content)
tags = atom.get("tags", [])
topic = tags[0] if tags else "this"
hook_line = f"[HOOK] (0:00-0:03)\n[Look directly at camera, energy up]\n\"{hook}\""
setup_points = points[:2]
setup_text = " ".join([shorten_sentence(p, 12) for p in setup_points])
setup_line = f"[SETUP] (0:03-0:13)\n[Cut to B-roll or screen share]\n\"{setup_text}\""
payoff_points = points[2:5] if len(points) > 2 else points
payoff_items = "\n".join([f"{shorten_sentence(p, 12)}" for p in payoff_points])
num_callout = f"\n[TEXT OVERLAY: {numbers[0]}]" if numbers else ""
payoff_line = f"[PAYOFF] (0:13-0:40)\n[Quick cuts between points]{num_callout}\n{payoff_items}"
cta_line = f"[CTA] (0:40-0:45)\n[Point at camera]\n\"Comment '{topic.upper()}' and I'll show you exactly how.\"\n[TEXT: Follow for more]"
script = f"{hook_line}\n\n{setup_line}\n\n{payoff_line}\n\n{cta_line}"
return script, hook
def generate_newsletter_section(atom):
content = clean_content(atom["content"])
hook = extract_hook(content, 150)
points = extract_key_points(content)
numbers = extract_numbers(content)
headline = f"**{hook}**"
punchy = make_punchy(content)
para1 = " ".join(punchy[:4])
para2 = " ".join(punchy[4:8]) if len(punchy) > 4 else ""
data = f"The numbers: {', '.join(numbers[:3])}." if numbers else ""
why = f"> **Why this matters:** {shorten_sentence(points[-1] if points else content[:100], 15)}"
parts = [headline, para1]
if para2:
parts.append(para2)
if data:
parts.append(data)
parts.append(why)
return "\n\n".join([p for p in parts if p.strip()]), hook
FORMAT_GENERATORS = {
"x_thread": generate_x_thread,
"x_post": generate_x_post,
"linkedin_post": generate_linkedin_post,
"youtube_short_script": generate_youtube_short,
"newsletter_section": generate_newsletter_section,
}
def estimate_engagement(atom, platform):
score = atom.get("repurpose_score", 5)
if score >= 8:
return "high"
elif score >= 5:
return "medium"
return "low"
def generate_drafts_for_atom(atom):
drafts = []
missing = atom.get("platforms_missing", [])
for platform_key in missing:
fmt = MISSING_TO_FORMAT.get(platform_key)
platform = MISSING_TO_PLATFORM.get(platform_key)
if not fmt or fmt not in FORMAT_GENERATORS:
continue
generator = FORMAT_GENERATORS[fmt]
draft_text, hook = generator(atom)
draft = {
"id": str(uuid.uuid4()),
"atom_id": atom["id"],
"atom_content": atom["content"][:500],
"atom_source": atom.get("source", "unknown"),
"platform": platform,
"format": fmt,
"draft": draft_text,
"hook": hook[:200],
"char_count": len(draft_text),
"estimated_engagement": estimate_engagement(atom, platform),
"created_at": datetime.now(timezone.utc).isoformat(),
"status": "draft",
"expert_score": None,
"iterations": 0,
"key_improvements": [],
}
drafts.append(draft)
return drafts
# ── ANTHROPIC API ──
def get_anthropic_key():
"""Get Anthropic API key from environment."""
key = os.environ.get("ANTHROPIC_API_KEY")
if key:
return key
print("ERROR: Set ANTHROPIC_API_KEY environment variable")
return None
def load_file_safe(path):
"""Load a text file, return empty string if missing."""
try:
return Path(path).read_text()
except Exception:
return ""
def load_expert_panel(platform):
"""Load expert panel for a platform."""
filename = PLATFORM_TO_EXPERT.get(platform, "x-articles.md")
return load_file_safe(SKILL_DIR / "experts" / filename)
def load_scoring_rubric():
"""Load content quality scoring rubric."""
return load_file_safe(SKILL_DIR / "scoring-rubrics" / "content-quality.md")
def load_voice_references():
"""Load voice/style references for content generation."""
voice_config = load_file_safe(VOICE_CONFIG_FILE)
style_guide = load_file_safe(STYLE_GUIDE_FILE)
return voice_config, style_guide
def call_anthropic(client, messages, system=None, model="claude-sonnet-4-20250514", max_tokens=2000):
"""Call Anthropic API."""
kwargs = {"model": model, "max_tokens": max_tokens, "messages": messages}
if system:
kwargs["system"] = system
response = client.messages.create(**kwargs)
return response.content[0].text.strip()
def llm_generate_draft(client, atom, platform, fmt, voice_config, style_guide):
"""Generate a draft using Claude API."""
platform_instructions = {
"x": "Write an X article (long-form X post). Include at least one ASCII diagram in a code block. Keep paragraphs to 1-3 sentences. End with a natural CTA.",
"linkedin": "Write a LinkedIn post. Hook must work before the 'see more' fold (first 2-3 lines). Use line breaks for readability. Professional but personal. 800-1500 chars.",
"youtube_short": "Write a YouTube Short script. Format: [HOOK] (0:00-0:03), [SETUP] (0:03-0:13), [PAYOFF] (0:13-0:40), [CTA] (0:40-0:45). Include visual directions. 30-60 seconds total.",
"newsletter": "Write a newsletter section. Subject line + scannable body. Headers, bullets, bold for skimmers. End with 'why this matters'.",
}
system_parts = ["You are a content writer creating platform-native content. Follow the configured voice and style EXACTLY."]
if voice_config:
system_parts.append(f"\nVOICE CONFIGURATION:\n{voice_config}")
if style_guide:
system_parts.append(f"\nSTYLE GUIDE:\n{style_guide[:2000]}")
system_parts.append("""
RULES:
- Short punchy sentences. Max 15 words.
- Specific numbers always. Never vague.
- Contrarian angles backed by data.
- No corporate speak. No "I'm excited to share."
- Personal stories and specific examples.
- Every sentence earns its place.""")
system = "\n".join(system_parts)
topic_tags = atom.get('tags', [])
prompt = f"""Create a {platform} draft from this content atom.
PLATFORM INSTRUCTIONS:
{platform_instructions.get(platform, platform_instructions['x'])}
SOURCE CONTENT:
{clean_content(atom['content'])}
SOURCE: {atom.get('source_title', 'unknown')}
TAGS: {', '.join(topic_tags)}
Write ONLY the draft content. No preamble, no explanation."""
return call_anthropic(client, [{"role": "user", "content": prompt}], system=system)
def expert_panel_score(client, draft_text, platform, expert_panel, rubric, voice_config):
"""Run expert panel scoring. Returns (score, feedback_dict)."""
system = f"""You are simulating 10 domain experts reviewing content for quality.
EXPERT PANEL:
{expert_panel}
SCORING RUBRIC:
{rubric}
VOICE REFERENCE:
{voice_config[:1000] if voice_config else 'No specific voice config provided.'}"""
prompt = f"""Score this {platform} draft. Each of 11 experts scores 0-100 on the rubric criteria.
Expert #11 is the AI Writing Detector (Humanizer) — scores how AI-generated the draft sounds.
BANNED AI VOCABULARY (flag any occurrence):
delve, tapestry, landscape (abstract), leverage, multifaceted, nuanced, pivotal, realm, robust, seamless, testament, transformative, underscore (verb), utilize, whilst, keen, embark, comprehensive, intricate, commendable, meticulous, paramount, groundbreaking, innovative, cutting-edge, synergy, holistic, paradigm, ecosystem, Additionally, crucial, enduring, enhance, fostering, garner, highlight (verb), interplay, intricacies, showcase, vibrant, valuable, profound, renowned, breathtaking, nestled, stunning
AI PATTERNS TO CHECK:
- Significance inflation ("pivotal moment", "is a testament", "stands as")
- Superficial -ing phrases ("highlighting", "showcasing", "underscoring")
- Promotional language ("boasts", "vibrant", "commitment to")
- Vague attributions ("Experts believe", "Industry reports")
- Formulaic "despite challenges... continues to" structures
- Copula avoidance ("serves as" instead of "is")
- Negative parallelisms ("It's not just X, it's Y")
- Rule-of-three forcing (triple adjectives/clauses)
- Em dash overuse (max 1 per 200 words)
- Filler phrases ("In order to", "It is important to note")
- Excessive hedging ("could potentially")
- Generic positive conclusions ("The future looks bright")
If the Humanizer expert scores below 70, the draft MUST be flagged for revision.
DRAFT:
{draft_text}
Respond in this EXACT JSON format (no other text):
{{
"average_score": <number>,
"expert_scores": [<11 numbers>],
"weaknesses": ["<specific weakness 1>", "<specific weakness 2>", ...],
"line_feedback": ["<specific line-by-line fix 1>", "<specific line-by-line fix 2>", ...],
"strengths": ["<strength 1>", "<strength 2>"],
"ai_patterns_detected": ["<pattern 1>", "<pattern 2>", ...],
"humanizer_score": <number>
}}
Be harsh. Score honestly."""
response = call_anthropic(client, [{"role": "user", "content": prompt}], system=system, max_tokens=1500)
try:
json_match = re.search(r'\{[\s\S]*\}', response)
if json_match:
result = json.loads(json_match.group())
return result.get("average_score", 0), result
else:
return 0, {"error": "No JSON in response"}
except json.JSONDecodeError:
return 0, {"error": "Invalid JSON", "raw": response[:500]}
def expert_panel_revise(client, draft_text, platform, feedback, voice_config, style_guide):
"""Revise draft based on expert feedback."""
system_parts = ["You are revising content based on expert feedback."]
if voice_config:
system_parts.append(f"\nVOICE CONFIGURATION:\n{voice_config}")
system_parts.append("""
RULES:
- Fix every weakness identified
- Keep all strengths
- Maintain configured voice exactly
- Short punchy sentences, specific numbers, contrarian angles""")
system = "\n".join(system_parts)
weaknesses = feedback.get("weaknesses", [])
line_fixes = feedback.get("line_feedback", [])
ai_patterns = feedback.get("ai_patterns_detected", [])
ai_section = ""
if ai_patterns:
ai_section = f"""
AI PATTERNS DETECTED (MUST FIX ALL):
{chr(10).join(f'- {p}' for p in ai_patterns)}
BANNED VOCABULARY (replace every occurrence):
delve, tapestry, landscape (abstract), leverage, multifaceted, nuanced, pivotal, realm, robust, seamless, testament, transformative, underscore (verb), utilize, whilst, keen, embark, comprehensive, intricate, commendable, meticulous, paramount, groundbreaking, innovative, cutting-edge, synergy, holistic, paradigm, ecosystem, Additionally, crucial, enduring, enhance, fostering, garner, highlight (verb), interplay, intricacies, showcase, vibrant, valuable, profound, renowned, breathtaking, nestled, stunning
"""
prompt = f"""Revise this {platform} draft based on expert feedback.
CURRENT DRAFT:
{draft_text}
WEAKNESSES TO FIX:
{chr(10).join(f'- {w}' for w in weaknesses)}
SPECIFIC LINE FIXES:
{chr(10).join(f'- {f}' for f in line_fixes)}
{ai_section}
CURRENT SCORE: {feedback.get('average_score', 'unknown')}
TARGET SCORE: {EXPERT_PANEL_THRESHOLD}+
Write ONLY the revised draft. No preamble."""
return call_anthropic(client, [{"role": "user", "content": prompt}], system=system)
def process_draft_with_expert_panel(client, atom, platform, fmt, voice_config, style_guide):
"""Full expert panel pipeline: generate → score → revise loop."""
expert_panel = load_expert_panel(platform)
rubric = load_scoring_rubric()
print(f" Generating {platform} draft via Claude...")
draft_text = llm_generate_draft(client, atom, platform, fmt, voice_config, style_guide)
iterations = []
best_draft = draft_text
best_score = 0
for iteration in range(1, EXPERT_PANEL_MAX_ITERATIONS + 1):
print(f" Expert panel scoring (iteration {iteration})...")
score, feedback = expert_panel_score(client, draft_text, platform, expert_panel, rubric, voice_config)
print(f" Score: {score}/100")
iteration_log = {
"iteration": iteration,
"score": score,
"weaknesses": feedback.get("weaknesses", []),
"line_feedback": feedback.get("line_feedback", []),
"strengths": feedback.get("strengths", []),
}
iterations.append(iteration_log)
if score > best_score:
best_score = score
best_draft = draft_text
if score >= EXPERT_PANEL_THRESHOLD:
print(f" ✓ Passed threshold ({score} >= {EXPERT_PANEL_THRESHOLD})")
break
if iteration < EXPERT_PANEL_MAX_ITERATIONS:
print(f" Revising based on feedback...")
draft_text = expert_panel_revise(client, draft_text, platform, feedback, voice_config, style_guide)
key_improvements = []
for it in iterations:
for w in it.get("weaknesses", []):
key_improvements.append(f"Iter {it['iteration']}: Fixed — {w}")
return best_draft, best_score, len(iterations), key_improvements, iterations
def rewrite_with_llm(drafts, use_expert_panel=False, expert_panel_top_n=10):
"""Rewrite drafts using Claude API, optionally with expert panel."""
try:
import anthropic
except ImportError:
print("ERROR: anthropic package not installed. Run: pip install anthropic")
return drafts
api_key = get_anthropic_key()
if not api_key:
return drafts
client = anthropic.Anthropic(api_key=api_key)
voice_config, style_guide = load_voice_references()
rewritten = []
for i, draft in enumerate(drafts):
atom = {"content": draft["atom_content"], "source_title": draft.get("atom_source", ""),
"tags": [], "atom_type": ""}
if use_expert_panel and i < expert_panel_top_n:
print(f"\n [{i+1}/{len(drafts)}] Expert panel: {draft['format']} (atom {draft['atom_id'][:8]})")
try:
import time as _time
_start = _time.time()
new_text, score, iters, improvements, iter_log = process_draft_with_expert_panel(
client, atom, draft["platform"], draft["format"],
voice_config, style_guide
)
_elapsed = _time.time() - _start
draft["draft"] = new_text
draft["hook"] = extract_hook(new_text, 200)
draft["char_count"] = len(new_text)
draft["expert_score"] = score
draft["iterations"] = iters
draft["key_improvements"] = improvements
draft["iteration_log"] = iter_log
status = "" if score >= EXPERT_PANEL_THRESHOLD else f"⚠ ({score})"
print(f" {status} Final: {score}/100 after {iters} iteration(s) [{_elapsed:.1f}s]")
except Exception as e:
print(f" ✗ Expert panel failed ({type(e).__name__}): {e}")
try:
new_text = llm_generate_draft(client, atom, draft["platform"], draft["format"],
voice_config, style_guide)
draft["draft"] = new_text
draft["hook"] = extract_hook(new_text, 200)
draft["char_count"] = len(new_text)
print(f" ↳ Fell back to simple LLM rewrite")
except Exception as e2:
print(f" ✗ LLM rewrite also failed: {e2}")
else:
print(f"\n [{i+1}/{len(drafts)}] LLM rewrite: {draft['format']} (atom {draft['atom_id'][:8]})")
try:
new_text = llm_generate_draft(client, atom, draft["platform"], draft["format"],
voice_config, style_guide)
draft["draft"] = new_text
draft["hook"] = extract_hook(new_text, 200)
draft["char_count"] = len(new_text)
print(f" ✓ Rewrote")
except Exception as e:
print(f" ✗ LLM rewrite failed: {e}")
rewritten.append(draft)
return rewritten
def main():
parser = argparse.ArgumentParser(description="Transform content atoms into platform-native drafts")
parser.add_argument("--atoms", type=str, help="Path to atoms JSON file")
parser.add_argument("--top-n", type=int, default=10, help="Number of top atoms to process")
parser.add_argument("--template-only", action="store_true", help="Use template-based generation (no LLM)")
parser.add_argument("--no-expert-panel", action="store_true", help="Disable expert panel quality gate")
parser.add_argument("--expert-panel-top-n", type=int, default=10, help="Apply expert panel to top N drafts")
parser.add_argument("--output", type=str, help="Output file path")
args = parser.parse_args()
use_llm = not args.template_only
use_expert_panel = use_llm and not args.no_expert_panel
atoms = load_atoms(args.atoms)
print(f"Loaded {len(atoms)} atoms")
top_atoms = rank_atoms(atoms, args.top_n)
print(f"Selected top {len(top_atoms)} atoms by repurpose_score × missing platforms")
all_drafts = []
for atom in top_atoms:
drafts = generate_drafts_for_atom(atom)
all_drafts.extend(drafts)
missing = atom.get("platforms_missing", [])
print(f" Atom {atom['id'][:8]}: {len(drafts)} drafts ({', '.join(missing)})")
print(f"\nGenerated {len(all_drafts)} total drafts")
if use_llm:
mode = "LLM + Expert Panel" if use_expert_panel else "LLM only"
print(f"\n{'='*60}")
print(f"Rewriting with {mode}...")
print(f"{'='*60}")
all_drafts = rewrite_with_llm(all_drafts, use_expert_panel=use_expert_panel,
expert_panel_top_n=args.expert_panel_top_n)
by_platform = {}
for d in all_drafts:
by_platform[d["platform"]] = by_platform.get(d["platform"], 0) + 1
print(f"\n{'='*60}")
print("Drafts by platform:")
for p, c in sorted(by_platform.items()):
print(f" {p}: {c}")
scored = [d for d in all_drafts if d.get("expert_score")]
if scored:
avg = sum(d["expert_score"] for d in scored) / len(scored)
passed = sum(1 for d in scored if d["expert_score"] >= EXPERT_PANEL_THRESHOLD)
print(f"\nExpert panel: {len(scored)} scored, {passed} passed (≥{EXPERT_PANEL_THRESHOLD}), avg {avg:.1f}")
today = datetime.now().strftime("%Y-%m-%d")
output_path = Path(args.output) if args.output else DATA_DIR / f"content-drafts-{today}.json"
latest_path = DATA_DIR / "content-drafts-latest.json"
output = {
"generated_at": datetime.now(timezone.utc).isoformat(),
"atom_count": len(top_atoms),
"draft_count": len(all_drafts),
"used_llm": use_llm,
"used_expert_panel": use_expert_panel,
"expert_panel_threshold": EXPERT_PANEL_THRESHOLD if use_expert_panel else None,
"drafts": all_drafts,
}
output_path.parent.mkdir(parents=True, exist_ok=True)
with open(output_path, "w") as f:
json.dump(output, f, indent=2)
with open(latest_path, "w") as f:
json.dump(output, f, indent=2)
print(f"\nSaved to {output_path}")
print(f"Saved to {latest_path}")
if scored:
print(f"\n{'='*60}")
print("TOP DRAFTS BY SCORE:")
print(f"{'='*60}")
for d in sorted(scored, key=lambda x: x["expert_score"], reverse=True)[:5]:
print(f"\n[{d['platform'].upper()}] Score: {d['expert_score']}/100 | Iterations: {d['iterations']}")
print(f"Hook: {d['hook'][:100]}...")
if d.get("key_improvements"):
print(f"Key improvements: {d['key_improvements'][0]}")
print(f"---")
print(d["draft"][:300])
print("...\n")
if __name__ == "__main__":
main()