- 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.
745 lines
27 KiB
Python
745 lines
27 KiB
Python
#!/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()
|