{"name":"Viral hook generator workflow","nodes":[{"id":"bb000001-1111-4222-8333-444455556601","name":"Weekly Hook Mining Run","type":"n8n-nodes-base.scheduleTrigger","typeVersion":1.2,"position":[-720,300],"parameters":{"rule":{"interval":[{"field":"days","daysInterval":7}]}}},{"id":"bb000002-1111-4222-8333-444455556602","name":"Fetch Video Ad Performance","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[-480,180],"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,frequency,ctr,purchase_roas,actions,action_values,video_3_sec_watched_actions,video_thruplay_watched_actions,video_p25_watched_actions,video_p75_watched_actions"},{"name":"action_attribution_windows","value":"7d_click,1d_view"},{"name":"date_preset","value":"last_30d"},{"name":"filtering","value":"[{\"field\":\"spend\",\"operator\":\"GREATER_THAN\",\"value\":100}]"},{"name":"limit","value":"500"}]},"options":{}}},{"id":"bb000003-1111-4222-8333-444455556603","name":"Fetch Ad Creative Copy","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[-480,440],"parameters":{"url":"https://graph.facebook.com/v21.0/act_ID/ads","sendQuery":true,"queryParameters":{"parameters":[{"name":"fields","value":"id,name,creative{body,title,thumbnail_url,object_story_spec}"},{"name":"effective_status","value":"[\"ACTIVE\",\"PAUSED\"]"},{"name":"limit","value":"500"}]},"options":{}}},{"id":"bb000004-1111-4222-8333-444455556604","name":"Read Existing Hook Bank","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[-480,-60],"parameters":{"operation":"read","documentId":{"__rl":true,"value":"SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=0","mode":"list","cachedResultName":"Hook Bank"},"options":{}}},{"id":"bb000005-1111-4222-8333-444455556605","name":"Merge Performance With Copy","type":"n8n-nodes-base.merge","typeVersion":3,"position":[-240,300],"parameters":{"mode":"append","options":{}}},{"id":"bb000006-1111-4222-8333-444455556606","name":"Score Thumbstop Rate","type":"n8n-nodes-base.code","typeVersion":2,"position":[0,300],"parameters":{"jsCode":"// ---- Hook mining config ---------------------------------------------------\n// A \"hook\" is the first 3 seconds. The only honest measure of it is thumbstop:\n// how many people who were served the impression actually stopped to watch.\nconst CFG = {\n  lookbackDays: 30,\n  minImpressions: 8000,   // below this, thumbstop is noise\n  minSpend: 100,          // don't mine hooks the algo never really tested\n  breakevenRoas: 1.6,\n  goodThumbstop: 0.30     // 30% 3s-view rate = the account benchmark\n};\n\nconst num = (v) => {\n  const n = typeof v === 'string' ? parseFloat(v) : v;\n  return Number.isFinite(n) ? n : 0;\n};\n\n// Meta returns these as [{action_type, value}] arrays\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};\nconst firstVal = (arr) => (Array.isArray(arr) && arr.length ? num(arr[0].value) : 0);\n\n// Graph API returns {data:[...]} — one wrapper item per request. Flatten either shape.\nconst rows = $input.all().flatMap((i) => (Array.isArray(i.json.data) ? i.json.data : [i.json]));\n\n// ---- 0. creative copy lookup ---------------------------------------------\n// The merge is positional, so an insights row can carry the copy of a DIFFERENT\n// ad. Rebuild the map from the /ads side keyed by its own id and look up by ad_id.\nconst copyById = new Map();\nfor (const r of rows) {\n  const cid = String(r.id || '');\n  const spec = (r.creative && (r.creative.object_story_spec || {})) || {};\n  const videoData = spec.video_data || {};\n  const linkData = spec.link_data || {};\n  const body = r.creative && (r.creative.body || videoData.message || linkData.message);\n  const title = r.creative && (r.creative.title || videoData.title || linkData.name);\n  if (cid && (body || title)) {\n    copyById.set(cid, {\n      body: String(body || ''),\n      title: String(title || ''),\n      thumbnail: (r.creative && r.creative.thumbnail_url) || null\n    });\n  }\n}\n\n// ---- 1. dedupe: insights return one row per ad per breakdown -------------\nconst byAd = new Map();\nfor (const r of rows) {\n  const id = String(r.ad_id || '');\n  if (!id) continue;\n  const prev = byAd.get(id);\n  if (!prev) { byAd.set(id, { ...r }); continue; }\n  prev.spend = num(prev.spend) + num(r.spend);\n  prev.impressions = num(prev.impressions) + num(r.impressions);\n  prev.actions = [...(prev.actions || []), ...(r.actions || [])];\n  prev.action_values = [...(prev.action_values || []), ...(r.action_values || [])];\n  prev.video_3_sec_watched_actions = [\n    ...(prev.video_3_sec_watched_actions || []), ...(r.video_3_sec_watched_actions || [])\n  ];\n  prev.video_thruplay_watched_actions = [\n    ...(prev.video_thruplay_watched_actions || []), ...(r.video_thruplay_watched_actions || [])\n  ];\n}\n\n// ---- 2. the hook itself is the first line / first 12 words of the copy ----\nconst extractHook = (body, adName) => {\n  const raw = String(body || '').replace(/\\s+/g, ' ').trim();\n  if (!raw) return String(adName || '').replace(/[_|-]+/g, ' ').trim();\n  const firstSentence = raw.split(/(?<=[.!?])\\s/)[0] || raw;\n  const words = firstSentence.split(' ');\n  return (words.length > 14 ? words.slice(0, 14).join(' ') + '…' : firstSentence).trim();\n};\n\nconst scored = [];\nfor (const r of byAd.values()) {\n  const spend = num(r.spend);\n  const impressions = num(r.impressions);\n  if (impressions < CFG.minImpressions || spend < CFG.minSpend) continue;\n\n  const threeSec = firstVal(r.video_3_sec_watched_actions);\n  const thruplay = firstVal(r.video_thruplay_watched_actions);\n  const p25 = firstVal(r.video_p25_watched_actions);\n  const p75 = firstVal(r.video_p75_watched_actions);\n  const purchases = pick(r.actions, 'purchase') || pick(r.actions, 'omni_purchase');\n  const revenue = pick(r.action_values, 'purchase');\n  const roas = Array.isArray(r.purchase_roas)\n    ? (pick(r.purchase_roas, 'purchase') || firstVal(r.purchase_roas))\n    : num(r.purchase_roas) || (spend ? revenue / spend : 0);\n\n  // thumbstop = 3s views / impressions. hold = thruplay / 3s views.\n  const thumbstop = impressions ? threeSec / impressions : 0;\n  const holdRate = threeSec ? thruplay / threeSec : 0;\n  const dropAfterHook = p25 ? Math.max(0, 1 - p75 / p25) : 0;\n  const copy = copyById.get(String(r.ad_id)) || { body: '', title: '' };\n  const hook = extractHook(copy.body || r.ad_name, r.ad_name);\n\n  // A hook only counts as \"worked\" if it stopped the scroll AND the traffic\n  // it stopped was worth having. Thumbstop with 0 ROAS is a curiosity trap.\n  const thumbstopIndex = thumbstop / CFG.goodThumbstop;\n  const roasIndex = CFG.breakevenRoas ? roas / CFG.breakevenRoas : 0;\n  const hookScore = Math.round(\n    100 * (0.55 * Math.min(2, thumbstopIndex) / 2 +\n           0.25 * Math.min(1, holdRate / 0.35) +\n           0.20 * Math.min(2, roasIndex) / 2)\n  );\n\n  scored.push({\n    json: {\n      ad_id: String(r.ad_id),\n      ad_name: r.ad_name || '(unnamed)',\n      campaign_name: r.campaign_name || '',\n      hook_text: hook,\n      full_body: copy.body,\n      headline: copy.title,\n      spend: Math.round(spend * 100) / 100,\n      impressions,\n      frequency: Math.round(num(r.frequency) * 100) / 100,\n      three_sec_views: threeSec,\n      thruplays: thruplay,\n      thumbstop_rate: Math.round(thumbstop * 10000) / 10000,\n      thumbstop_index: Math.round(thumbstopIndex * 100) / 100,\n      hold_rate: Math.round(holdRate * 10000) / 10000,\n      drop_after_hook: Math.round(dropAfterHook * 10000) / 10000,\n      purchases,\n      roas: Math.round(roas * 100) / 100,\n      hook_score: hookScore,\n      // the gate the IF below reads: beat the benchmark AND at least pay for itself\n      is_winner: thumbstopIndex >= 1.15 && roas >= CFG.breakevenRoas * 0.75 && hook.length > 8,\n      mined_at: new Date().toISOString()\n    }\n  });\n}\n\nscored.sort((a, b) => b.json.hook_score - a.json.hook_score);\nreturn scored;"}},{"id":"bb000007-1111-4222-8333-444455556607","name":"Hook Actually Worked?","type":"n8n-nodes-base.if","typeVersion":2,"position":[240,300],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"h1","leftValue":"={{ $json.is_winner }}","rightValue":"","operator":{"type":"boolean","operation":"true","singleValue":true}}]},"options":{}}},{"id":"bb000008-1111-4222-8333-444455556608","name":"Archive Weak Hooks","type":"n8n-nodes-base.code","typeVersion":2,"position":[480,560],"parameters":{"jsCode":"// Losers are data too: a hook that stopped nobody tells the LLM what NOT to write,\n// and a hook that stopped everybody but sold nothing is a curiosity trap to avoid.\nconst CFG = { goodThumbstop: 0.30, breakevenRoas: 1.6 };\n\nreturn $input.all().map((i) => i.json).map((r) => {\n  const stopped = Number(r.thumbstop_index) >= 1.15;\n  const sold = Number(r.roas) >= CFG.breakevenRoas * 0.75;\n  let failure_mode;\n  if (!stopped && !sold) failure_mode = 'invisible — nobody stopped, nobody bought';\n  else if (stopped && !sold) failure_mode = 'curiosity trap — stopped the scroll, wrong audience';\n  else failure_mode = 'weak open — sold fine but the first 3s wasted reach';\n\n  return { json: {\n    ad_id: r.ad_id,\n    hook_text: r.hook_text,\n    campaign_name: r.campaign_name,\n    spend: r.spend,\n    impressions: r.impressions,\n    thumbstop_rate: r.thumbstop_rate,\n    hold_rate: r.hold_rate,\n    roas: r.roas,\n    hook_score: r.hook_score,\n    status: 'rejected_source',\n    failure_mode,\n    // fed back as an anti-pattern next run\n    avoid_note: 'Do not reuse this opening: ' + failure_mode,\n    archived_at: new Date().toISOString()\n  } };\n});"}},{"id":"bb000009-1111-4222-8333-444455556609","name":"Log Weak Hooks To Sheet","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[720,560],"parameters":{"operation":"append","documentId":{"__rl":true,"value":"SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=2","mode":"list","cachedResultName":"Anti Patterns"},"columns":{"mappingMode":"autoMapInputData","value":{},"matchingColumns":[]},"options":{}}},{"id":"bb00000a-1111-4222-8333-44445555660a","name":"Extract Winning Hook Patterns","type":"n8n-nodes-base.code","typeVersion":2,"position":[480,300],"parameters":{"jsCode":"// Turn winning hooks into reusable PATTERNS. We are not going to ask the model to\n// \"write good hooks\" — we are going to hand it the exact skeletons that already\n// beat the benchmark in this account, with the numbers attached.\nconst winners = $input.all().map((i) => i.json);\n\nconst PATTERNS = [\n  { id: 'callout',    test: (h) => /^(attention|hey|calling all|if you(?:'re| are)\\b|for (?:every|any)\\b)/i.test(h),\n    skeleton: '[AUDIENCE CALLOUT] + [specific pain]' },\n  { id: 'question',   test: (h) => /\\?/.test(h),\n    skeleton: 'Provocative question the prospect answers \"yes\" to in their head' },\n  { id: 'stat',       test: (h) => /\\d+(?:[.,]\\d+)?\\s*(%|x\\b|percent|days?|weeks?|hours?|lbs?|\\$)/i.test(h),\n    skeleton: '[SPECIFIC NUMBER] + [outcome or timeframe]' },\n  { id: 'negation',   test: (h) => /\\b(stop|never|don'?t|quit|nobody|no one|not)\\b/i.test(h),\n    skeleton: 'Tell them to STOP doing the thing they already do' },\n  { id: 'confession', test: (h) => /\\b(i |we |my |our )/i.test(h) && /\\b(tried|used to|wasted|spent|made|switched)\\b/i.test(h),\n    skeleton: 'First-person confession / before-after story open' },\n  { id: 'secret',     test: (h) => /\\b(nobody tells you|secret|truth|actually|really|what .* won'?t)\\b/i.test(h),\n    skeleton: 'Insider truth the category hides' },\n  { id: 'compare',    test: (h) => /\\bvs\\.?\\b|\\binstead of\\b|\\bbetter than\\b/i.test(h),\n    skeleton: '[Familiar thing] vs [our thing]' }\n];\n\nconst classify = (hook) => {\n  const hits = PATTERNS.filter((p) => p.test(hook)).map((p) => p.id);\n  return hits.length ? hits : ['direct_claim'];\n};\n\n// group winners by pattern and weight each group by the spend that proved it\nconst groups = new Map();\nfor (const w of winners) {\n  for (const pid of classify(w.hook_text)) {\n    if (!groups.has(pid)) groups.set(pid, { pattern_id: pid, examples: [], spend: 0, ts: 0, roas: 0, n: 0 });\n    const g = groups.get(pid);\n    g.examples.push(w.hook_text);\n    g.spend += Number(w.spend) || 0;\n    g.ts += Number(w.thumbstop_rate) || 0;\n    g.roas += Number(w.roas) || 0;\n    g.n += 1;\n  }\n}\n\nconst out = [...groups.values()]\n  .map((g) => {\n    const skeleton = (PATTERNS.find((p) => p.id === g.pattern_id) || {}).skeleton || 'Plain benefit claim';\n    const avgTs = g.ts / g.n;\n    const avgRoas = g.roas / g.n;\n    // confidence: how much money backs this pattern, capped so one big spender\n    // can't own the whole brief\n    const weight = Math.min(1, g.spend / 5000) * Math.min(1, g.n / 3);\n    return { json: {\n      row_type: 'pattern',\n      pattern_id: g.pattern_id,\n      skeleton,\n      winners_count: g.n,\n      backing_spend: Math.round(g.spend),\n      avg_thumbstop: Math.round(avgTs * 10000) / 10000,\n      avg_roas: Math.round(avgRoas * 100) / 100,\n      weight: Math.round(weight * 100) / 100,\n      // how many new hooks to ask for in this pattern (proportional to proof)\n      quota: Math.max(2, Math.round(weight * 6)),\n      example_hooks: g.examples.slice(0, 4)\n    } };\n  })\n  .filter((r) => r.json.winners_count >= 1)\n  .sort((a, b) => b.json.backing_spend - a.json.backing_spend)\n  .slice(0, 6);\n\nreturn out;"}},{"id":"bb00000b-1111-4222-8333-44445555660b","name":"Merge Patterns With Hook Bank","type":"n8n-nodes-base.merge","typeVersion":3,"position":[720,300],"parameters":{"mode":"append","options":{}}},{"id":"bb00000c-1111-4222-8333-44445555660c","name":"Build Generation Brief","type":"n8n-nodes-base.code","typeVersion":2,"position":[960,300],"parameters":{"jsCode":"// The merge hands us two kinds of rows: freshly mined patterns (row_type=pattern)\n// and every hook we have ever banked (from the Hook Bank sheet). Split them apart,\n// then compile ONE brief item for the model.\nconst all = $input.all().map((i) => i.json);\n\nconst patterns = all.filter((r) => r.row_type === 'pattern' && r.pattern_id);\nconst banked = all\n  .filter((r) => !r.row_type && (r.hook_text || r.hook))\n  .map((r) => String(r.hook_text || r.hook).trim())\n  .filter(Boolean);\n\n// dedupe the bank (the sheet accumulates near-duplicates over months)\nconst pastHooks = [...new Set(banked)];\n\nconst totalQuota = patterns.reduce((s, p) => s + Number(p.quota || 0), 0) || 12;\n\nconst patternBrief = patterns.map((p, i) =>\n  (i + 1) + ') PATTERN \"' + p.pattern_id + '\" — ' + p.skeleton + '\\n' +\n  '   proof: ' + p.winners_count + ' winning ads, $' + p.backing_spend + ' spend, ' +\n  (p.avg_thumbstop * 100).toFixed(1) + '% thumbstop, ' + p.avg_roas + ' ROAS\\n' +\n  '   write ' + p.quota + ' new hooks in this pattern\\n' +\n  '   examples that worked:\\n' +\n  (p.example_hooks || []).map((h) => '     - ' + h).join('\\n')\n).join('\\n\\n');\n\n// only the most recent 60 past hooks go in the prompt — the full bank is used\n// for the numeric novelty check downstream, not for prompt stuffing\nconst avoidList = pastHooks.slice(-60).map((h) => '- ' + h).join('\\n');\n\nreturn [{ json: {\n  pattern_count: patterns.length,\n  requested_hooks: totalQuota,\n  past_hook_count: pastHooks.length,\n  past_hooks: pastHooks,\n  pattern_ids: patterns.map((p) => p.pattern_id),\n  pattern_brief: patternBrief || 'No pattern hit the proof threshold this run — write direct benefit claims.',\n  avoid_list: avoidList || '(bank is empty)',\n  brief_built_at: new Date().toISOString()\n} }];"}},{"id":"bb00000d-1111-4222-8333-44445555660d","name":"Generate New Hooks","type":"@n8n/n8n-nodes-langchain.openAi","typeVersion":1.8,"position":[1200,300],"parameters":{"modelId":{"__rl":true,"value":"gpt-4o-mini","mode":"list"},"messages":{"values":[{"role":"system","content":"You are a direct response copywriter who writes the first 3 seconds of paid social video ads. You write the way people talk. You never use marketing filler (\"unlock\", \"elevate\", \"game-changing\", \"dive into\"). Every hook must be one sentence, under 15 words, and must work as spoken audio. Return ONLY a JSON array: [{\"pattern_id\":\"...\",\"hook\":\"...\"}] with no prose and no code fences."},{"content":"=Write {{ $json.requested_hooks }} new video hooks for our product.\n\nUse ONLY these patterns, which are the ones that beat our 30% thumbstop benchmark in the last 30 days. Stay inside each pattern's quota and set pattern_id to the pattern name:\n\n{{ $json.pattern_brief }}\n\nThese {{ $json.past_hook_count }} hooks are already in our bank. Do not rewrite, paraphrase, or reuse the structure of any of them — a downstream novelty check will reject anything with heavy word overlap:\n\n{{ $json.avoid_list }}\n\nReturn the JSON array only."}]},"options":{}}},{"id":"bb00000e-1111-4222-8333-44445555660e","name":"Score Hook Novelty","type":"n8n-nodes-base.code","typeVersion":2,"position":[1440,300],"parameters":{"jsCode":"// Parse the model output, then score every generated hook for NOVELTY against the\n// entire hook bank. Novelty is the whole point: a generator that regurgitates last\n// month's hooks just burns creative budget on ads the audience already ignored.\nconst brief = $('Build Generation Brief').first().json;\nconst pastHooks = (brief.past_hooks || []).map((h) => String(h));\nconst allowedPatterns = new Set(brief.pattern_ids || []);\n\n// ---- 1. get the raw text out of whatever shape the LLM node returned ------\nconst item = $input.first().json;\nconst raw =\n  (item.message && item.message.content) ||\n  item.content ||\n  item.text ||\n  (Array.isArray(item.choices) && item.choices[0] && item.choices[0].message && item.choices[0].message.content) ||\n  '';\n\nlet parsed = [];\ntry {\n  const cleaned = String(raw).replace(/^\\s*```(?:json)?/i, '').replace(/```\\s*$/, '').trim();\n  const j = JSON.parse(cleaned);\n  parsed = Array.isArray(j) ? j : (j.hooks || []);\n} catch (e) {\n  // fall back to line parsing: \"pattern | hook text\"\n  parsed = String(raw).split('\\n')\n    .map((l) => l.replace(/^\\s*[-*\\d.)\\]]+\\s*/, '').trim())\n    .filter((l) => l.length > 10)\n    .map((l) => {\n      const bar = l.indexOf('|');\n      return bar > 0 && bar < 24\n        ? { pattern_id: l.slice(0, bar).trim(), hook: l.slice(bar + 1).trim() }\n        : { pattern_id: 'unknown', hook: l };\n    });\n}\n\n// ---- 2. normalise + shingle ----------------------------------------------\nconst STOP = new Set(['the','a','an','and','or','but','to','of','for','in','on','is','it','you','your','my','we','i','that','this','with','are','was']);\nconst norm = (s) => String(s).toLowerCase().replace(/[^a-z0-9\\s]/g, ' ').replace(/\\s+/g, ' ').trim();\nconst tokens = (s) => norm(s).split(' ').filter((w) => w && !STOP.has(w));\nconst bigrams = (s) => {\n  const t = tokens(s);\n  const g = new Set(t);            // unigrams catch shared rare nouns\n  for (let i = 0; i < t.length - 1; i++) g.add(t[i] + ' ' + t[i + 1]);\n  return g;\n};\nconst jaccard = (a, b) => {\n  if (!a.size || !b.size) return 0;\n  let inter = 0;\n  for (const v of a) if (b.has(v)) inter++;\n  return inter / (a.size + b.size - inter);\n};\n\nconst pastGrams = pastHooks.map((h) => ({ hook: h, grams: bigrams(h) }));\n\n// ---- 3. score each candidate ---------------------------------------------\nconst seen = [];\nconst out = [];\n\nfor (const c of parsed) {\n  const hook = String((c && (c.hook || c.hook_text || c.text)) || '').trim();\n  if (hook.length < 12 || hook.length > 140) continue;\n  const grams = bigrams(hook);\n\n  // similarity vs the bank\n  let worst = 0, nearest = '';\n  for (const p of pastGrams) {\n    const s = jaccard(grams, p.grams);\n    if (s > worst) { worst = s; nearest = p.hook; }\n  }\n  // similarity vs the other hooks generated in THIS batch (models self-repeat)\n  let intraDup = 0;\n  for (const s of seen) intraDup = Math.max(intraDup, jaccard(grams, s));\n  seen.push(grams);\n\n  const novelty = Math.round((1 - Math.max(worst, intraDup)) * 100);\n  const patternId = String((c && (c.pattern_id || c.pattern)) || 'unknown').trim();\n  const onPattern = allowedPatterns.has(patternId);\n\n  // craft signals that correlate with thumbstop in short-form\n  const wordCount = hook.split(/\\s+/).length;\n  const hasNumber = /\\d/.test(hook);\n  const hasSecond = /\\byou\\b|\\byour\\b/i.test(hook);\n  const hasSlop = /(unlock|elevate|game[- ]chang|revolutionary|in today'?s world|dive into)/i.test(hook);\n  const craft = Math.round(\n    (wordCount >= 4 && wordCount <= 14 ? 30 : wordCount <= 18 ? 15 : 0) +\n    (hasNumber ? 20 : 0) + (hasSecond ? 20 : 0) + (onPattern ? 30 : 10) - (hasSlop ? 35 : 0)\n  );\n\n  // final = novelty is the veto, craft is the tiebreak\n  const finalScore = Math.round(0.6 * novelty + 0.4 * Math.max(0, Math.min(100, craft)));\n\n  out.push({ json: {\n    hook_text: hook,\n    pattern_id: onPattern ? patternId : 'unmatched',\n    word_count: wordCount,\n    novelty_score: novelty,\n    nearest_past_hook: nearest || '(bank empty)',\n    nearest_similarity: Math.round(worst * 100) / 100,\n    intra_batch_similarity: Math.round(intraDup * 100) / 100,\n    craft_score: Math.max(0, Math.min(100, craft)),\n    has_number: hasNumber,\n    uses_second_person: hasSecond,\n    slop_flag: hasSlop,\n    final_score: finalScore,\n    // the gate: must be meaningfully different from anything banked, and not slop\n    is_fresh: novelty >= 65 && !hasSlop && finalScore >= 60,\n    generated_at: new Date().toISOString()\n  } });\n}\n\nout.sort((a, b) => b.json.final_score - a.json.final_score);\nreturn out;"}},{"id":"bb00000f-1111-4222-8333-44445555660f","name":"Novel Enough To Bank?","type":"n8n-nodes-base.if","typeVersion":2,"position":[1680,300],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"n1","leftValue":"={{ $json.is_fresh }}","rightValue":"","operator":{"type":"boolean","operation":"true","singleValue":true}}]},"options":{}}},{"id":"bb000010-1111-4222-8333-444455556610","name":"Queue Rewrite Notes","type":"n8n-nodes-base.code","typeVersion":2,"position":[1920,560],"parameters":{"jsCode":"// Rejects are not deleted — they are the training signal for next week's prompt.\n// Each one gets the reason it died so the next brief can name it explicitly.\nreturn $input.all().map((i) => i.json).map((r) => {\n  const reasons = [];\n  if (r.novelty_score < 65) reasons.push('too close to \"' + r.nearest_past_hook + '\" (' + Math.round(r.nearest_similarity * 100) + '% overlap)');\n  if (r.intra_batch_similarity >= 0.35) reasons.push('duplicates another hook in the same batch');\n  if (r.slop_flag) reasons.push('contains banned AI filler language');\n  if (r.pattern_id === 'unmatched') reasons.push('does not follow any proven pattern');\n  if (r.word_count > 18) reasons.push('too long to land in 3 seconds');\n  if (!reasons.length) reasons.push('scored ' + r.final_score + ', under the 60 point bar');\n\n  return { json: {\n    hook_text: r.hook_text,\n    pattern_id: r.pattern_id,\n    novelty_score: r.novelty_score,\n    craft_score: r.craft_score,\n    final_score: r.final_score,\n    status: 'rejected',\n    reject_reason: reasons.join('; '),\n    // fed straight into next run's avoid list\n    prompt_feedback: 'Avoid: ' + reasons[0],\n    rejected_at: new Date().toISOString()\n  } };\n});"}},{"id":"bb000011-1111-4222-8333-444455556611","name":"Log Rejected Hooks","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[2160,560],"parameters":{"operation":"append","documentId":{"__rl":true,"value":"SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=3","mode":"list","cachedResultName":"Rejected Hooks"},"columns":{"mappingMode":"autoMapInputData","value":{},"matchingColumns":[]},"options":{}}},{"id":"bb000012-1111-4222-8333-444455556612","name":"Rank And Cap The Drop","type":"n8n-nodes-base.code","typeVersion":2,"position":[1920,300],"parameters":{"jsCode":"// Cap the drop. Shipping 40 hooks a week means none of them get a fair test —\n// we ship the top N and force pattern diversity so we don't test 12 variants of\n// the same sentence.\nconst CFG = { maxPerDrop: 15, maxPerPattern: 4 };\n\nconst rows = $input.all().map((i) => i.json)\n  .sort((a, b) => b.final_score - a.final_score);\n\nconst perPattern = new Map();\nconst kept = [];\nfor (const r of rows) {\n  const p = r.pattern_id || 'unmatched';\n  const n = perPattern.get(p) || 0;\n  if (n >= CFG.maxPerPattern) continue;\n  perPattern.set(p, n + 1);\n  kept.push(r);\n  if (kept.length >= CFG.maxPerDrop) break;\n}\n\nconst week = new Date().toISOString().slice(0, 10);\nreturn kept.map((r, i) => ({ json: {\n  hook_id: 'HK-' + week + '-' + String(i + 1).padStart(2, '0'),\n  hook_text: r.hook_text,\n  pattern_id: r.pattern_id,\n  novelty_score: r.novelty_score,\n  craft_score: r.craft_score,\n  final_score: r.final_score,\n  nearest_past_hook: r.nearest_past_hook,\n  nearest_similarity: r.nearest_similarity,\n  word_count: r.word_count,\n  rank: i + 1,\n  status: 'ready_to_test',\n  banked_at: new Date().toISOString()\n} }));"}},{"id":"bb000013-1111-4222-8333-444455556613","name":"Save To Hook Bank","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[2160,300],"parameters":{"operation":"append","documentId":{"__rl":true,"value":"SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=0","mode":"list","cachedResultName":"Hook Bank"},"columns":{"mappingMode":"autoMapInputData","value":{},"matchingColumns":[]},"options":{}}},{"id":"bb000014-1111-4222-8333-444455556614","name":"Post Hook Drop To Slack","type":"n8n-nodes-base.slack","typeVersion":2.2,"position":[2400,300],"parameters":{"select":"channel","channelId":{"__rl":true,"value":"#creative-hooks","mode":"name"},"text":"=🪝 *{{ $json.hook_id }}* · pattern `{{ $json.pattern_id }}` · novelty {{ $json.novelty_score }}/100\n> {{ $json.hook_text }}\nClosest thing we already ran: _{{ $json.nearest_past_hook }}_","otherOptions":{}}},{"id":"bb000015-1111-4222-8333-444455556615","name":"Note Mine","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[-760,-160],"parameters":{"content":"## 1. MINE WHAT WORKED\nLast 30 days of video ads. Thumbstop = video_3_sec_watched / impressions, benchmark 30%. A hook only counts as a winner if it beat the benchmark AND the traffic it stopped hit at least 0.75x breakeven ROAS — otherwise it is a curiosity trap.","height":300,"width":440,"color":4}},{"id":"bb000016-1111-4222-8333-444455556616","name":"Note Patterns","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[460,-60],"parameters":{"content":"## 2. PATTERNS → PROMPT\nWinners are classified into 8 skeletons (callout, question, stat, negation, confession, secret, compare, direct claim). Each pattern gets a quota proportional to the spend backing it, so the model writes more of what actually has proof.","height":300,"width":440,"color":3}},{"id":"bb000017-1111-4222-8333-444455556617","name":"Note Novelty","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[1660,-60],"parameters":{"content":"## 3. NOVELTY GATE\nEvery generated hook is bigram-Jaccard scored against the whole hook bank AND against the rest of its own batch. novelty >= 65, no AI filler, score >= 60 to bank. Rejects keep their reason and feed next week's avoid list.","height":300,"width":440,"color":5}}],"connections":{"Weekly Hook Mining Run":{"main":[[{"node":"Fetch Video Ad Performance","type":"main","index":0},{"node":"Fetch Ad Creative Copy","type":"main","index":0},{"node":"Read Existing Hook Bank","type":"main","index":0}]]},"Fetch Video Ad Performance":{"main":[[{"node":"Merge Performance With Copy","type":"main","index":0}]]},"Fetch Ad Creative Copy":{"main":[[{"node":"Merge Performance With Copy","type":"main","index":1}]]},"Merge Performance With Copy":{"main":[[{"node":"Score Thumbstop Rate","type":"main","index":0}]]},"Score Thumbstop Rate":{"main":[[{"node":"Hook Actually Worked?","type":"main","index":0}]]},"Hook Actually Worked?":{"main":[[{"node":"Extract Winning Hook Patterns","type":"main","index":0}],[{"node":"Archive Weak Hooks","type":"main","index":0}]]},"Archive Weak Hooks":{"main":[[{"node":"Log Weak Hooks To Sheet","type":"main","index":0}]]},"Extract Winning Hook Patterns":{"main":[[{"node":"Merge Patterns With Hook Bank","type":"main","index":0}]]},"Read Existing Hook Bank":{"main":[[{"node":"Merge Patterns With Hook Bank","type":"main","index":1}]]},"Merge Patterns With Hook Bank":{"main":[[{"node":"Build Generation Brief","type":"main","index":0}]]},"Build Generation Brief":{"main":[[{"node":"Generate New Hooks","type":"main","index":0}]]},"Generate New Hooks":{"main":[[{"node":"Score Hook Novelty","type":"main","index":0}]]},"Score Hook Novelty":{"main":[[{"node":"Novel Enough To Bank?","type":"main","index":0}]]},"Novel Enough To Bank?":{"main":[[{"node":"Rank And Cap The Drop","type":"main","index":0}],[{"node":"Queue Rewrite Notes","type":"main","index":0}]]},"Rank And Cap The Drop":{"main":[[{"node":"Save To Hook Bank","type":"main","index":0}]]},"Save To Hook Bank":{"main":[[{"node":"Post Hook Drop To Slack","type":"main","index":0}]]},"Queue Rewrite Notes":{"main":[[{"node":"Log Rejected Hooks","type":"main","index":0}]]}},"settings":{"executionOrder":"v1"},"pinData":{}}