{"name":"100 ad creatives a day workflow","nodes":[{"id":"42000001-1111-4222-8333-444455556601","name":"Every Morning At 6","type":"n8n-nodes-base.scheduleTrigger","typeVersion":1.2,"position":[-940,300],"parameters":{"rule":{"interval":[{"field":"days","daysInterval":1}]}}},{"id":"42000002-1111-4222-8333-444455556602","name":"Read Creative Briefs","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[-700,180],"parameters":{"operation":"read","documentId":{"__rl":true,"value":"SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=0","mode":"list","cachedResultName":"Briefs"},"options":{}}},{"id":"42000003-1111-4222-8333-444455556603","name":"Fetch Winning Angle Benchmarks","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[-700,420],"parameters":{"url":"https://graph.facebook.com/v21.0/act_ID/insights","sendQuery":true,"queryParameters":{"parameters":[{"name":"level","value":"ad"},{"name":"fields","value":"ad_id,ad_name,campaign_name,spend,impressions,ctr,frequency,purchase_roas,actions"},{"name":"date_preset","value":"last_30d"},{"name":"filtering","value":"[{\"field\":\"spend\",\"operator\":\"GREATER_THAN\",\"value\":200}]"},{"name":"limit","value":"300"}]},"options":{}}},{"id":"42000004-1111-4222-8333-444455556604","name":"Merge Briefs With Benchmarks","type":"n8n-nodes-base.merge","typeVersion":3,"position":[-460,300],"parameters":{"mode":"append","options":{}}},{"id":"42000005-1111-4222-8333-444455556605","name":"Expand Briefs Into Angle Jobs","type":"n8n-nodes-base.code","typeVersion":2,"position":[-220,300],"parameters":{"jsCode":"// ---- Batch config: how the 100/day number actually gets hit ---------------\n// 10 briefs x 5 angles x 4 variants = 200 raw -> dedupe/guardrails leaves ~100.\nconst CFG = {\n  variantsPerAngle: 4,\n  maxAnglesPerBrief: 5,\n  dailyTarget: 100,\n  minBenchmarkSpend: 200   // an angle only counts as \"proven\" past this spend\n};\n\n// Angle library. Each one is a distinct psychological entry point, not a reword.\nconst ANGLE_LIBRARY = [\n  { key: 'problem_agitate', label: 'Problem / Agitate', brief: 'Open on the painful status quo, twist the knife, then position the product as relief.' },\n  { key: 'mechanism',       label: 'Unique Mechanism',  brief: 'Lead with HOW it works. Name the mechanism. Make it sound proprietary and specific.' },\n  { key: 'social_proof',    label: 'Social Proof',      brief: 'Lead with numbers of customers, reviews, or a named testimonial. Third-party voice.' },\n  { key: 'us_vs_them',      label: 'Us vs Them',        brief: 'Contrast against the category default the customer already tried and hated.' },\n  { key: 'founder_story',   label: 'Founder Story',     brief: 'First person. Why we built it. Anti-corporate, plain language, one concrete detail.' },\n  { key: 'offer_urgency',   label: 'Offer / Urgency',   brief: 'Lead with the deal and a real deadline or stock constraint. No fake scarcity.' },\n  { key: 'objection_kill',  label: 'Objection Killer',  brief: 'Name the #1 reason they would not buy and dismantle it in the first line.' }\n];\n\nconst num = (v) => { const n = typeof v === 'string' ? parseFloat(v) : v; return Number.isFinite(n) ? n : 0; };\nconst pick = (arr, type) => {\n  if (!Array.isArray(arr)) return 0;\n  const hit = arr.find((a) => a.action_type === type);\n  return hit ? num(hit.value) : 0;\n};\n\nconst rows = $input.all().map((i) => i.json);\n\n// ---- 1. Learn which angles are actually working from live Meta data -------\n// Benchmark rows come from the /insights fetch and carry ad_name like\n// \"SS-mechanism-v3\". We parse the angle key out of the naming convention.\n// The Graph API returns ONE item shaped { data: [ ...ads ], paging: {} }, so we\n// unwrap it; a flat ad_name row is also accepted in case insights arrive split.\nconst benchRows = [];\nfor (const r of rows) {\n  if (Array.isArray(r.data)) benchRows.push(...r.data);\n  else if (r.ad_name) benchRows.push(r);\n}\n\nconst angleStats = new Map();\nfor (const r of benchRows) {\n  const name = String(r.ad_name || '');\n  if (!name) continue;\n  const spend = num(r.spend);\n  if (spend < CFG.minBenchmarkSpend) continue;\n  const roas = Array.isArray(r.purchase_roas)\n    ? pick(r.purchase_roas, 'purchase')\n    : num(r.purchase_roas);\n  const hit = ANGLE_LIBRARY.find((a) => name.toLowerCase().includes(a.key.split('_')[0]));\n  if (!hit) continue;\n  const prev = angleStats.get(hit.key) || { spend: 0, weightedRoas: 0 };\n  prev.spend += spend;\n  prev.weightedRoas += roas * spend;\n  angleStats.set(hit.key, prev);\n}\nconst angleRoas = (key) => {\n  const s = angleStats.get(key);\n  return s && s.spend > 0 ? s.weightedRoas / s.spend : null;\n};\n\n// ---- 2. Collect the briefs (rows that came off the brief sheet) ----------\nconst briefs = rows.filter((r) => r.product || r.product_name).map((r) => ({\n  brief_id: String(r.brief_id || r.id || r.product || '').trim(),\n  product: String(r.product || r.product_name || '').trim(),\n  audience: String(r.audience || 'cold traffic, 25-45').trim(),\n  pain: String(r.pain || r.problem || '').trim(),\n  offer: String(r.offer || '').trim(),\n  proof: String(r.proof || '').trim(),\n  banned_words: String(r.banned_words || '').split(',').map((w) => w.trim().toLowerCase()).filter(Boolean),\n  landing_url: String(r.landing_url || '').trim()\n})).filter((b) => b.product);\n\nif (!briefs.length) return [];\n\n// ---- 3. Rank angles per brief: proven ROAS first, then unexplored ones ---\n// Unexplored angles get a neutral prior so we never fully starve exploration.\nconst NEUTRAL_PRIOR = 1.4;\nconst ranked = ANGLE_LIBRARY\n  .map((a) => {\n    const observed = angleRoas(a.key);\n    return { ...a, observed, score: observed === null ? NEUTRAL_PRIOR : observed, proven: observed !== null };\n  })\n  .sort((x, y) => y.score - x.score);\n\n// Always keep at least one unproven angle in the mix (explore slot).\nconst chosen = ranked.slice(0, CFG.maxAnglesPerBrief);\nif (!chosen.some((a) => !a.proven)) {\n  const explorer = ranked.find((a) => !a.proven);\n  if (explorer) chosen[chosen.length - 1] = explorer;\n}\n\n// ---- 4. Fan out one job per brief x angle -------------------------------\nconst jobs = [];\nfor (const b of briefs) {\n  for (const a of chosen) {\n    jobs.push({ json: {\n      job_id: b.brief_id + '::' + a.key,\n      brief_id: b.brief_id,\n      product: b.product,\n      audience: b.audience,\n      pain: b.pain,\n      offer: b.offer,\n      proof: b.proof,\n      banned_words: b.banned_words,\n      landing_url: b.landing_url,\n      angle_key: a.key,\n      angle_label: a.label,\n      angle_brief: a.brief,\n      angle_observed_roas: a.observed === null ? null : Math.round(a.observed * 100) / 100,\n      angle_is_explore: !a.proven,\n      variants_requested: CFG.variantsPerAngle,\n      daily_target: CFG.dailyTarget,\n      batch_date: new Date().toISOString().slice(0, 10)\n    } });\n  }\n}\n\n// Proven angles first so if we hit a rate limit we lost the cheap experiments.\njobs.sort((p, q) => (q.json.angle_observed_roas || 0) - (p.json.angle_observed_roas || 0));\nreturn jobs;"}},{"id":"42000006-1111-4222-8333-444455556606","name":"Loop Over Angle Jobs","type":"n8n-nodes-base.splitInBatches","typeVersion":3,"position":[20,300],"parameters":{"batchSize":1,"options":{}}},{"id":"42000007-1111-4222-8333-444455556607","name":"Generate Copy Variants","type":"@n8n/n8n-nodes-langchain.openAi","typeVersion":1.8,"position":[260,60],"parameters":{"modelId":{"__rl":true,"value":"gpt-4o-mini","mode":"list"},"messages":{"values":[{"role":"system","content":"You are a direct response copywriter who writes Meta ads that sound like a real person, not a brand. No emoji spam, no \"unlock\", no \"elevate\", no \"game-changer\". Reply with ONLY a JSON object: {\"variants\":[{\"headline\":\"\",\"primary_text\":\"\",\"description\":\"\"}]}. Each variant must be a genuinely different execution of the given angle — different hook, different proof, different rhythm. Do not rephrase the same sentence."},{"content":"=Angle: {{ $json.angle_label }}\nAngle instruction: {{ $json.angle_brief }}\n\nProduct: {{ $json.product }}\nAudience: {{ $json.audience }}\nCore pain: {{ $json.pain }}\nOffer: {{ $json.offer }}\nProof we can cite: {{ $json.proof }}\nNever use these words: {{ $json.banned_words }}\n\nWrite {{ $json.variants_requested }} variants. Headline 25-40 chars, primary text 120-320 chars, description under 30 chars."}]},"options":{}}},{"id":"42000008-1111-4222-8333-444455556608","name":"Parse And Normalise Variants","type":"n8n-nodes-base.code","typeVersion":2,"position":[500,60],"parameters":{"jsCode":"// The LLM returns a JSON blob in message.content. Parse it defensively:\n// a single bad response should cost us one angle, not the whole 100-ad batch.\nconst job = $('Loop Over Angle Jobs').item.json;\n\nconst raw = $input.all().map((i) => i.json);\nconst text = raw\n  .map((r) => r.message && r.message.content ? r.message.content : (r.content || r.text || ''))\n  .join('\\n');\n\nlet parsed = null;\ntry {\n  parsed = JSON.parse(text);\n} catch (e) {\n  // Model wrapped it in prose or a fence — grab the outermost {...} or [...]\n  const m = text.match(/[\\[{][\\s\\S]*[\\]}]/);\n  if (m) { try { parsed = JSON.parse(m[0]); } catch (e2) { parsed = null; } }\n}\n\nconst list = Array.isArray(parsed) ? parsed : (parsed && Array.isArray(parsed.variants) ? parsed.variants : []);\n\nif (!list.length) {\n  return [{ json: {\n    job_id: job.job_id, brief_id: job.brief_id, product: job.product,\n    angle_key: job.angle_key, angle_label: job.angle_label,\n    parse_failed: true, headline: '', primary_text: '', description: '',\n    batch_date: job.batch_date\n  } }];\n}\n\nconst clean = (s) => String(s == null ? '' : s).replace(/\\s+/g, ' ').replace(/^[\"'\\s]+|[\"'\\s]+$/g, '');\n\nreturn list.slice(0, job.variants_requested || 4).map((v, idx) => {\n  const primary = clean(v.primary_text || v.body || v.primary);\n  const headline = clean(v.headline || v.title);\n  const description = clean(v.description || v.subhead);\n  const hitBanned = (job.banned_words || []).filter((w) =>\n    (primary + ' ' + headline + ' ' + description).toLowerCase().includes(w));\n  return { json: {\n    variant_id: job.job_id + '::v' + (idx + 1),\n    job_id: job.job_id,\n    brief_id: job.brief_id,\n    product: job.product,\n    audience: job.audience,\n    landing_url: job.landing_url,\n    angle_key: job.angle_key,\n    angle_label: job.angle_label,\n    angle_observed_roas: job.angle_observed_roas,\n    angle_is_explore: job.angle_is_explore,\n    headline,\n    primary_text: primary,\n    description,\n    headline_chars: headline.length,\n    primary_chars: primary.length,\n    banned_hits: hitBanned,\n    parse_failed: false,\n    batch_date: job.batch_date,\n    generated_at: new Date().toISOString()\n  } };\n});"}},{"id":"42000009-1111-4222-8333-444455556609","name":"Dedupe Near Identical Copy","type":"n8n-nodes-base.code","typeVersion":2,"position":[260,300],"parameters":{"jsCode":"// ---- Near-duplicate killer ------------------------------------------------\n// An LLM asked for 200 variants will hand you the same ad 4 different ways.\n// We cluster by trigram Jaccard similarity on the normalised primary text and\n// keep exactly one representative per cluster (the longest, most specific one).\nconst CFG = {\n  similarityThreshold: 0.62,  // >= this on trigrams == same ad\n  headlineExactBlock: true,   // never ship two identical headlines\n  minPrimaryChars: 60,        // too short to be a real primary text\n  maxPrimaryChars: 900\n};\n\nconst STOP = new Set(['the','a','an','and','or','but','to','of','for','in','on','with','your','you','it','is','are','that','this','we','our','my','i','at','as','be','so','if','from','by','just','get','can']);\n\nconst normalise = (s) => String(s || '')\n  .toLowerCase()\n  .replace(/https?:\\/\\/\\S+/g, ' ')\n  .replace(/[^a-z0-9 ]+/g, ' ')\n  .replace(/\\s+/g, ' ')\n  .trim();\n\nconst tokens = (s) => normalise(s).split(' ').filter((w) => w && !STOP.has(w));\n\nconst trigrams = (s) => {\n  const t = tokens(s);\n  if (t.length < 3) return new Set(t);\n  const g = new Set();\n  for (let i = 0; i <= t.length - 3; i++) g.add(t[i] + ' ' + t[i + 1] + ' ' + t[i + 2]);\n  return g;\n};\n\nconst jaccard = (a, b) => {\n  if (!a.size || !b.size) return 0;\n  let inter = 0;\n  for (const x of a) if (b.has(x)) inter++;\n  return inter / (a.size + b.size - inter);\n};\n\nconst all = $input.all().map((i) => i.json);\n\n// ---- 1. hard rejects before we bother comparing anything -----------------\nconst rejects = [];\nconst candidates = [];\nfor (const v of all) {\n  if (v.parse_failed) { rejects.push({ ...v, reject_reason: 'llm response did not parse' }); continue; }\n  if (!v.primary_text || v.primary_chars < CFG.minPrimaryChars) {\n    rejects.push({ ...v, reject_reason: 'primary text too short (' + (v.primary_chars || 0) + ' chars)' }); continue;\n  }\n  if (v.primary_chars > CFG.maxPrimaryChars) {\n    rejects.push({ ...v, reject_reason: 'primary text too long (' + v.primary_chars + ' chars)' }); continue;\n  }\n  if (Array.isArray(v.banned_hits) && v.banned_hits.length) {\n    rejects.push({ ...v, reject_reason: 'banned words: ' + v.banned_hits.join(', ') }); continue;\n  }\n  candidates.push({ ...v, _grams: trigrams(v.primary_text), _headKey: normalise(v.headline) });\n}\n\n// Longest first: when two variants collide we want to keep the specific one,\n// not the generic one-liner that happens to sit inside it.\ncandidates.sort((a, b) => b.primary_chars - a.primary_chars);\n\n// ---- 2. greedy clustering, scoped per brief ------------------------------\n// Two different products saying \"sleep better tonight\" is fine. The same\n// product saying it twice is not — so we only compare within a brief_id.\nconst keptByBrief = new Map();\nconst seenHeadlines = new Map();\nconst unique = [];\n\nfor (const c of candidates) {\n  const brief = c.brief_id || 'unscoped';\n  const kept = keptByBrief.get(brief) || [];\n\n  if (CFG.headlineExactBlock && c._headKey) {\n    const heads = seenHeadlines.get(brief) || new Set();\n    if (heads.has(c._headKey)) {\n      rejects.push({ ...c, _grams: undefined, reject_reason: 'duplicate headline within brief' });\n      continue;\n    }\n  }\n\n  let dupOf = null;\n  let bestSim = 0;\n  for (const k of kept) {\n    const sim = jaccard(c._grams, k._grams);\n    if (sim > bestSim) { bestSim = sim; if (sim >= CFG.similarityThreshold) dupOf = k; }\n  }\n\n  if (dupOf) {\n    rejects.push({ ...c, _grams: undefined, reject_reason: 'near-duplicate of ' + dupOf.variant_id + ' (sim ' + bestSim.toFixed(2) + ')' });\n    continue;\n  }\n\n  kept.push(c);\n  keptByBrief.set(brief, kept);\n  const heads = seenHeadlines.get(brief) || new Set();\n  if (c._headKey) heads.add(c._headKey);\n  seenHeadlines.set(brief, heads);\n\n  const out = { ...c };\n  delete out._grams;\n  delete out._headKey;\n  out.max_similarity_to_kept = Math.round(bestSim * 100) / 100;\n  unique.push(out);\n}\n\n// ---- 3. one summary field rides on every row so the IF can read it -------\nconst stamp = {\n  total_generated: all.length,\n  unique_kept: unique.length,\n  deduped_out: rejects.length,\n  dedupe_rate: all.length ? Math.round((rejects.length / all.length) * 100) : 0\n};\n\nif (!unique.length) {\n  return [{ json: { ...stamp, unique_kept: 0, rejects, empty_batch: true } }];\n}\n\nreturn unique.map((u) => ({ json: { ...u, ...stamp, rejects: undefined } }))\n  .concat([]);"}},{"id":"42000010-1111-4222-8333-444455556610","name":"Enough Unique Variants?","type":"n8n-nodes-base.if","typeVersion":2,"position":[500,300],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"u1","leftValue":"={{ $json.unique_kept }}","rightValue":40,"operator":{"type":"number","operation":"gte"}}]},"options":{}}},{"id":"42000011-1111-4222-8333-444455556611","name":"Ask For More Briefs In Slack","type":"n8n-nodes-base.slack","typeVersion":2.2,"position":[740,560],"parameters":{"select":"channel","channelId":{"__rl":true,"value":"#creative-ops","mode":"name"},"text":"=⚠️ Creative batch came up short — only {{ $json.unique_kept }} unique variants survived dedupe out of {{ $json.total_generated }} generated ({{ $json.dedupe_rate }}% were near-duplicates).\nThe brief sheet is too thin or the angles are overlapping. Add 3-5 fresh briefs with distinct pains before tomorrow 6am.","otherOptions":{}}},{"id":"42000012-1111-4222-8333-444455556612","name":"Score And Rank Batch","type":"n8n-nodes-base.code","typeVersion":2,"position":[740,300],"parameters":{"jsCode":"// ---- Rank the surviving variants so the queue is ordered, not a pile -----\n// Score = angle evidence + copy craft signals. Nothing here is magic: it is\n// the checklist a buyer runs by eye, written down so it runs on 100 ads.\nconst CFG = {\n  idealHeadlineChars: [25, 40],\n  idealPrimaryChars: [120, 320],\n  exploreBonus: 0.15,\n  guardrailFloor: 0.55\n};\n\nconst rows = $input.all().map((i) => i.json);\n\nconst inRange = (n, [lo, hi]) => n >= lo && n <= hi;\nconst hasNumber = (s) => /\\d/.test(String(s || ''));\nconst hasQuestion = (s) => /\\?/.test(String(s || ''));\nconst firstLine = (s) => String(s || '').split(/[.!?\\n]/)[0].trim();\n\nconst scored = rows.map((r) => {\n  let score = 0.4;\n  const notes = [];\n\n  // angle evidence (what Meta already told us works)\n  const roas = Number(r.angle_observed_roas);\n  if (Number.isFinite(roas) && roas > 0) {\n    const lift = Math.min(0.25, (roas - 1.4) * 0.12);\n    score += lift;\n    notes.push('angle ' + r.angle_key + ' at ' + roas.toFixed(2) + ' ROAS live');\n  } else if (r.angle_is_explore) {\n    score += CFG.exploreBonus;\n    notes.push('explore slot, no live data yet');\n  }\n\n  // craft signals\n  if (inRange(Number(r.headline_chars), CFG.idealHeadlineChars)) { score += 0.08; }\n  else notes.push('headline ' + r.headline_chars + ' chars outside ideal range');\n\n  if (inRange(Number(r.primary_chars), CFG.idealPrimaryChars)) { score += 0.08; }\n  else notes.push('primary ' + r.primary_chars + ' chars outside ideal range');\n\n  const hook = firstLine(r.primary_text);\n  if (hook.length > 0 && hook.length <= 70) score += 0.06;\n  else notes.push('hook line is ' + hook.length + ' chars — cut it');\n\n  if (hasNumber(r.primary_text)) { score += 0.05; notes.push('has a concrete number'); }\n  if (hasQuestion(hook)) score += 0.03;\n\n  // penalty: near-duplicate that only just squeaked past the threshold\n  const sim = Number(r.max_similarity_to_kept || 0);\n  if (sim > 0.45) { score -= 0.08; notes.push('close to another variant (sim ' + sim.toFixed(2) + ')'); }\n\n  score = Math.max(0, Math.min(1, score));\n\n  return { json: {\n    ...r,\n    hook,\n    quality_score: Math.round(score * 100) / 100,\n    quality_notes: notes.join(' · '),\n    cleared_guardrails: score >= CFG.guardrailFloor,\n    queue_status: score >= CFG.guardrailFloor ? 'ready' : 'needs_rewrite'\n  } };\n});\n\nscored.sort((a, b) => b.json.quality_score - a.json.quality_score);\n\n// rank position is what the designer works down from tomorrow morning\nscored.forEach((s, i) => { s.json.queue_rank = i + 1; });\nreturn scored;"}},{"id":"42000013-1111-4222-8333-444455556613","name":"Cleared Brand Guardrails?","type":"n8n-nodes-base.if","typeVersion":2,"position":[980,300],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"g1","leftValue":"={{ $json.cleared_guardrails }}","rightValue":"","operator":{"type":"boolean","operation":"true","singleValue":true}}]},"options":{}}},{"id":"42000014-1111-4222-8333-444455556614","name":"Log Rewrites Needed To Sheet","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[1220,560],"parameters":{"operation":"append","documentId":{"__rl":true,"value":"SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=2","mode":"list","cachedResultName":"Needs Rewrite"},"columns":{"mappingMode":"autoMapInputData","value":{},"matchingColumns":[]},"options":{}}},{"id":"42000015-1111-4222-8333-444455556615","name":"Write Batch To Creative Queue","type":"n8n-nodes-base.airtable","typeVersion":2.1,"position":[1220,300],"parameters":{"operation":"create","base":{"__rl":true,"value":"appXXXXXXXX","mode":"id"},"table":{"__rl":true,"value":"tblXXXXXXXX","mode":"id"},"columns":{"mappingMode":"autoMapInputData","value":{}},"options":{}}},{"id":"42000016-1111-4222-8333-444455556616","name":"Build Batch Summary","type":"n8n-nodes-base.code","typeVersion":2,"position":[1460,300],"parameters":{"jsCode":"const rows = $input.all().map((i) => i.json);\nif (!rows.length) {\n  return [{ json: { slack_text: '⚠️ Creative batch produced 0 queue-ready variants today.', queued: 0 } }];\n}\n\nconst first = rows[0];\nconst byAngle = {};\nfor (const r of rows) {\n  const k = r.angle_label || r.angle_key || 'unknown';\n  byAngle[k] = (byAngle[k] || 0) + 1;\n}\nconst byBrief = {};\nfor (const r of rows) {\n  const k = r.product || r.brief_id || 'unknown';\n  byBrief[k] = (byBrief[k] || 0) + 1;\n}\n\nconst avg = rows.reduce((s, r) => s + Number(r.quality_score || 0), 0) / rows.length;\nconst top = rows.slice(0, 5).map((r, i) =>\n  (i + 1) + '. *' + (r.headline || '(no headline)') + '* — ' + r.product +\n  ' / ' + r.angle_label + ' (score ' + r.quality_score + ')\\n     ↳ ' + r.hook);\n\nconst target = Number(first.daily_target || 100);\nconst pace = Math.round((rows.length / target) * 100);\n\nconst slack_text = [\n  '🎨 *Creative batch ' + (first.batch_date || '') + ' — ' + rows.length + ' variants queued*',\n  'Generated ' + (first.total_generated || '?') + ' · deduped out ' + (first.deduped_out || 0) +\n    ' (' + (first.dedupe_rate || 0) + '%) · avg quality ' + (Math.round(avg * 100) / 100),\n  'Pace against the ' + target + '/day target: ' + pace + '%',\n  'By angle: ' + Object.entries(byAngle).map(([k, v]) => k + ' ' + v).join(' · '),\n  'By product: ' + Object.entries(byBrief).map(([k, v]) => k + ' ' + v).join(' · '),\n  '',\n  'Top of the queue:',\n  ...top\n].join('\\n');\n\nreturn [{ json: {\n  batch_date: first.batch_date,\n  queued: rows.length,\n  total_generated: first.total_generated,\n  deduped_out: first.deduped_out,\n  dedupe_rate: first.dedupe_rate,\n  avg_quality: Math.round(avg * 100) / 100,\n  pace_pct: pace,\n  slack_text\n} }];"}},{"id":"42000017-1111-4222-8333-444455556617","name":"Post Batch Summary To Slack","type":"n8n-nodes-base.slack","typeVersion":2.2,"position":[1700,300],"parameters":{"select":"channel","channelId":{"__rl":true,"value":"#creative-ops","mode":"name"},"text":"={{ $json.slack_text }}","otherOptions":{}}},{"id":"42000018-1111-4222-8333-444455556618","name":"Note Inputs","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[-980,20],"parameters":{"content":"## 1. BRIEFS + WHAT ALREADY WORKS\nEvery morning: pull the brief sheet (product, audience, pain, offer, proof, banned words) and 30 days of ad-level insights (spend, purchase_roas, frequency). Live ROAS per angle decides which angles get fanned out today.","height":280,"width":440,"color":4}},{"id":"42000019-1111-4222-8333-444455556619","name":"Note Generation","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[0,-220],"parameters":{"content":"## 2. FAN OUT AND GENERATE\n10 briefs x 5 angles x 4 variants = ~200 raw ads. One angle per loop pass so a single bad LLM response costs one angle, not the batch. One explore slot is always reserved for an unproven angle.","height":250,"width":460,"color":3}},{"id":"42000020-1111-4222-8333-444455556620","name":"Note Dedupe","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[240,620],"parameters":{"content":"## 3. DEDUPE, SCORE, QUEUE\nTrigram Jaccard clustering (>= 0.62 = same ad) scoped per brief kills the LLM's rephrasings. Survivors get a quality score from angle evidence + copy craft, then land ranked in the Airtable creative queue. Rejects go to the rewrite sheet, never the bin.","height":260,"width":480,"color":5}}],"connections":{"Every Morning At 6":{"main":[[{"node":"Read Creative Briefs","type":"main","index":0},{"node":"Fetch Winning Angle Benchmarks","type":"main","index":0}]]},"Read Creative Briefs":{"main":[[{"node":"Merge Briefs With Benchmarks","type":"main","index":0}]]},"Fetch Winning Angle Benchmarks":{"main":[[{"node":"Merge Briefs With Benchmarks","type":"main","index":1}]]},"Merge Briefs With Benchmarks":{"main":[[{"node":"Expand Briefs Into Angle Jobs","type":"main","index":0}]]},"Expand Briefs Into Angle Jobs":{"main":[[{"node":"Loop Over Angle Jobs","type":"main","index":0}]]},"Loop Over Angle Jobs":{"main":[[{"node":"Dedupe Near Identical Copy","type":"main","index":0}],[{"node":"Generate Copy Variants","type":"main","index":0}]]},"Generate Copy Variants":{"main":[[{"node":"Parse And Normalise Variants","type":"main","index":0}]]},"Parse And Normalise Variants":{"main":[[{"node":"Loop Over Angle Jobs","type":"main","index":0}]]},"Dedupe Near Identical Copy":{"main":[[{"node":"Enough Unique Variants?","type":"main","index":0}]]},"Enough Unique Variants?":{"main":[[{"node":"Score And Rank Batch","type":"main","index":0}],[{"node":"Ask For More Briefs In Slack","type":"main","index":0}]]},"Score And Rank Batch":{"main":[[{"node":"Cleared Brand Guardrails?","type":"main","index":0}]]},"Cleared Brand Guardrails?":{"main":[[{"node":"Write Batch To Creative Queue","type":"main","index":0}],[{"node":"Log Rewrites Needed To Sheet","type":"main","index":0}]]},"Write Batch To Creative Queue":{"main":[[{"node":"Build Batch Summary","type":"main","index":0}]]},"Build Batch Summary":{"main":[[{"node":"Post Batch Summary To Slack","type":"main","index":0}]]}},"settings":{"executionOrder":"v1"},"pinData":{}}