{
  "id": "cdTsbS89f6ZoBmfj",
  "name": "Filter, Deduplicate and Approve News Articles with Embeddings and a GPT Judge",
  "tags": [
    {
      "id": "WiPfwUZYcO7Er2qC",
      "name": "Template",
      "createdAt": "2026-02-10T16:54:20.430Z",
      "updatedAt": "2026-02-10T16:54:20.430Z"
    }
  ],
  "nodes": [
    {
      "id": "sticky_config",
      "name": "Sticky config",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -976,
        -400
      ],
      "parameters": {
        "color": 4,
        "width": 480,
        "height": 764,
        "content": "## Edit knobs here, nothing else\nAll tuning lives in Configure context:\n- **sheetDocumentId / sourceTab / articlesTab / approvedTab** - the spreadsheet, the incoming-feed tab, the seen-articles ledger tab, and the output tab\n- **rulesKeywords** - term-to-weight map for Step 1; a headline hit counts double a body hit (word-boundary matched)\n- **sourceWeights** - domain-to-weight map (live articles carry their feed's domain as source); unknown domains score 0\n- **rulesRejectThreshold** - score below this = rejected immediately (default 2)\n- **rulesHardWinThreshold** - score at or above this skips the LLM judge (default 12)\n- **clusterSimilarityThreshold** - cosine similarity at which two articles are the same story (default 0.80; raise it and clusters fragment, lower it and distinct stories merge)\n- **embeddingProvider** - huggingface or openai; both branches emit the same shape\n- **hfEmbeddingModel / openaiEmbeddingModel** - embedding model per provider\n- **judgeModel** - OpenAI chat model used by the Step 3 judge\n- **clearBeforeRun** - true wipes the ledger and output tabs (headers always survive) first (clean demo take); false keeps history, and articles already in the ledger are dropped before any money is spent\n- **rssFeedUrls** - JSON array of RSS feed URLs, all fetched every run; add as many as you like\n- **useLiveFetch** - true (default) pulls every feed in rssFeedUrls into the run; false reads only the Source Articles tab\n- **nicheContext** - the judge's definition of relevant; swap it to point the whole pipeline at another niche"
      },
      "typeVersion": 1
    },
    {
      "id": "d38ca2d6-5964-49d5-9523-b9423e859d2c",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -464,
        -160
      ],
      "parameters": {
        "color": 7,
        "width": 416,
        "height": 528,
        "content": "## Triggers and context\n\nStarts the pipeline manually or on a daily schedule, then sets the spreadsheet, tab, feed, and run configuration used by downstream nodes."
      },
      "typeVersion": 1
    },
    {
      "id": "d2faf635-1be5-4ea9-b28e-c6ee7ab86fc7",
      "name": "Sticky Note2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -16,
        -288
      ],
      "parameters": {
        "color": 7,
        "width": 864,
        "height": 480,
        "content": "## Fresh run cleanup\n\nChecks whether this run should start fresh, clears prior approved packages and the seen ledger when requested, then rejoins the normal flow."
      },
      "typeVersion": 1
    },
    {
      "id": "07a4b82e-48d7-4ca7-b447-e01a034fea1a",
      "name": "Sticky Note3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        208,
        208
      ],
      "parameters": {
        "color": 7,
        "width": 864,
        "height": 304,
        "content": "## Live RSS intake\n\nOptionally fetches live articles by splitting configured feed URLs, reading each RSS feed, and mapping results into the same article shape as the sheet data."
      },
      "typeVersion": 1
    },
    {
      "id": "881a1639-ffd4-4da9-9556-72c9a9b01490",
      "name": "Sticky Note4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        880,
        -352
      ],
      "parameters": {
        "color": 7,
        "width": 416,
        "height": 544,
        "content": "## Read source records\n\nReads source articles from Google Sheets, appends any mapped live articles, and reads the existing seen ledger for deduplication."
      },
      "typeVersion": 1
    },
    {
      "id": "e1df064e-6455-497f-add1-c66a85de908a",
      "name": "Sticky Note5",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1328,
        -80
      ],
      "parameters": {
        "color": 7,
        "width": 640,
        "height": 320,
        "content": "## Deduplicate new URLs\n\nDrops articles whose URLs are already in the ledger, stamps the remaining items with pending defaults, and records them as seen before scoring."
      },
      "typeVersion": 1
    },
    {
      "id": "83f04724-d163-4124-ae37-dd71fa0f4547",
      "name": "Sticky Note6",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2000,
        -80
      ],
      "parameters": {
        "color": 7,
        "width": 640,
        "height": 320,
        "content": "## Rule score candidates\n\nApplies deterministic rule-based scoring, removes rejected noise, and collects the surviving items into a batch for embedding."
      },
      "typeVersion": 1
    },
    {
      "id": "9afc69f8-6401-4ee2-a55e-a46100b50b4c",
      "name": "Sticky Note7",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2672,
        -144
      ],
      "parameters": {
        "color": 7,
        "width": 640,
        "height": 528,
        "content": "## Generate article embeddings\n\nSelects an embedding provider, sends the batch to Hugging Face or OpenAI, and normalizes either provider\u2019s vector response into a common vectors field."
      },
      "typeVersion": 1
    },
    {
      "id": "19e029ba-3d6a-4b93-9c5e-73fcb88d8f28",
      "name": "Sticky Note8",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        3344,
        -80
      ],
      "parameters": {
        "color": 7,
        "width": 416,
        "height": 320,
        "content": "## Cluster and route\n\nClusters embedded articles to group near-duplicate coverage of the same story, then decides which clusters require LLM judgment."
      },
      "typeVersion": 1
    },
    {
      "id": "fb8639bf-a6e1-4188-bb2f-072d0d0c834f",
      "name": "Sticky Note9",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        3792,
        -144
      ],
      "parameters": {
        "color": 7,
        "width": 400,
        "height": 528,
        "content": "## GPT relevance judge\n\nRuns the OpenAI-powered LLM judge for ambiguous items and parses its decision into a structured verdict."
      },
      "typeVersion": 1
    },
    {
      "id": "53cb95eb-a1de-4f35-b2a0-8d9e7f10bf48",
      "name": "Sticky Note10",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        4224,
        -80
      ],
      "parameters": {
        "color": 7,
        "width": 416,
        "height": 320,
        "content": "## Combine approved paths\n\nMerges items that bypassed the judge with judged results, then assembles final approved story packages from the clustered and scored data."
      },
      "typeVersion": 1
    },
    {
      "id": "64599911-5ea6-4268-b520-06bbc9fe0984",
      "name": "Sticky Note11",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        4672,
        -208
      ],
      "parameters": {
        "color": 7,
        "height": 384,
        "content": "## Write approved packages\n\nWrites the assembled approved story packages to the configured approved output sheet."
      },
      "typeVersion": 1
    },
    {
      "id": "58d2365d-d1dc-40e0-8b95-49b2546d9ecb",
      "name": "Sticky Note12",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        4672,
        192
      ],
      "parameters": {
        "color": 7,
        "width": 416,
        "height": 352,
        "content": "## Update outcome ledger\n\nCompiles every article\u2019s final fate and updates the seen ledger so future runs can skip or report on previously processed URLs."
      },
      "typeVersion": 1
    },
    {
      "id": "293a50f6-37ee-471c-9eb4-0a78d598c687",
      "name": "Manual Pipeline Trigger",
      "type": "n8n-nodes-base.manualTrigger",
      "position": [
        -416,
        0
      ],
      "parameters": {},
      "typeVersion": 1
    },
    {
      "id": "db541963-7378-4615-b2e9-ebcbc7ae31c1",
      "name": "Set Workflow Context",
      "type": "n8n-nodes-base.set",
      "position": [
        -192,
        80
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "c01",
              "name": "sheetDocumentId",
              "type": "string",
              "value": "11UAshmcWzrzNZRBqeDSL78r7b6GWxou-UJdxhCXRlUY"
            },
            {
              "id": "c16",
              "name": "sourceTab",
              "type": "string",
              "value": "Source Articles"
            },
            {
              "id": "c02",
              "name": "articlesTab",
              "type": "string",
              "value": "Articles"
            },
            {
              "id": "c03",
              "name": "approvedTab",
              "type": "string",
              "value": "Approved Package"
            },
            {
              "id": "c04",
              "name": "rulesKeywords",
              "type": "object",
              "value": "{\"ai\": 1, \"artificial intelligence\": 2, \"model\": 2, \"llm\": 2, \"agent\": 2, \"openai\": 2, \"anthropic\": 2, \"chip\": 1, \"compute\": 2, \"inference\": 2, \"open source\": 1, \"lawsuit\": 1, \"regulation\": 1, \"data center\": 1, \"funding\": 1, \"gpu\": 1, \"voice\": 1, \"copyright\": 1}"
            },
            {
              "id": "c05",
              "name": "sourceWeights",
              "type": "object",
              "value": "{\"techcrunch.com\": 2, \"theverge.com\": 2, \"arstechnica.com\": 2, \"venturebeat.com\": 1, \"wired.com\": 1, \"reuters.com\": 2}"
            },
            {
              "id": "c06",
              "name": "rulesRejectThreshold",
              "type": "number",
              "value": 2
            },
            {
              "id": "c07",
              "name": "rulesHardWinThreshold",
              "type": "number",
              "value": 12
            },
            {
              "id": "c08",
              "name": "clusterSimilarityThreshold",
              "type": "number",
              "value": 0.8
            },
            {
              "id": "c15",
              "name": "clearBeforeRun",
              "type": "boolean",
              "value": false
            },
            {
              "id": "c09",
              "name": "embeddingProvider",
              "type": "string",
              "value": "huggingface"
            },
            {
              "id": "c10",
              "name": "hfEmbeddingModel",
              "type": "string",
              "value": "sentence-transformers/all-MiniLM-L6-v2"
            },
            {
              "id": "c11",
              "name": "openaiEmbeddingModel",
              "type": "string",
              "value": "text-embedding-3-small"
            },
            {
              "id": "c12",
              "name": "judgeModel",
              "type": "string",
              "value": "gpt-5-mini"
            },
            {
              "id": "c17",
              "name": "rssFeedUrls",
              "type": "array",
              "value": "[\"https://techcrunch.com/category/artificial-intelligence/feed/\"]"
            },
            {
              "id": "c14",
              "name": "useLiveFetch",
              "type": "boolean",
              "value": true
            },
            {
              "id": "c13",
              "name": "nicheContext",
              "type": "string",
              "value": "News for people who build AI products and automation: model releases, AI infrastructure and compute, AI business deals, AI regulation, and AI engineering practice."
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "dca7b91a-c863-444e-a444-bdd69a97d085",
      "name": "Clear Package Data in Sheets",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        256,
        -112
      ],
      "parameters": {
        "operation": "clear",
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "={{ $('Set Workflow Context').first().json.approvedTab }}"
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $('Set Workflow Context').first().json.sheetDocumentId }}"
        },
        "keepFirstRow": true
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.7
    },
    {
      "id": "7e2b0a7f-6133-4090-94ab-3efbf4cc0eca",
      "name": "Read Articles from Sheets",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        928,
        -192
      ],
      "parameters": {
        "options": {},
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "={{ $('Set Workflow Context').first().json.sourceTab }}"
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $('Set Workflow Context').first().json.sheetDocumentId }}"
        }
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.7
    },
    {
      "id": "4c4ae13e-726f-4d8d-bf4c-64f87efd6a96",
      "name": "Apply Scoring Rules",
      "type": "n8n-nodes-base.code",
      "position": [
        2048,
        80
      ],
      "parameters": {
        "jsCode": "// Step 1 of 3: deterministic scoring. Free, instant, and it never changes its mind.\n// Code instead of a native IF-chain: the keyword and source weight maps live in\n// 'Configure context', and native nodes cannot iterate a configurable map.\nconst config = $('Set Workflow Context').first().json;\nconst keywordWeights = config.rulesKeywords;\nconst sourceWeights = config.sourceWeights;\n\nconst scoredItems = [];\nfor (const item of $input.all()) {\n  const article = item.json;\n  const titleText = String(article.title || '').toLowerCase();\n  const bodyText = (String(article.summary || '') + ' ' + String(article.content_excerpt || '')).toLowerCase();\n\n  // A keyword in the headline is worth double a keyword buried in the body.\n  // Word-boundary matching: 'ai' must not match inside 'bargain'.\n  let keywordScore = 0;\n  for (const [term, weight] of Object.entries(keywordWeights)) {\n    const escapedTerm = term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n    const termPattern = new RegExp(`\\\\b${escapedTerm}\\\\b`, 'i');\n    if (termPattern.test(titleText)) keywordScore += weight * 2;\n    else if (termPattern.test(bodyText)) keywordScore += weight;\n  }\n  const sourceScore = sourceWeights[article.source] || 0;\n  const rulesScore = keywordScore + sourceScore;\n\n  let rulesBand = 'pass';\n  if (rulesScore < config.rulesRejectThreshold) rulesBand = 'reject';\n  else if (rulesScore >= config.rulesHardWinThreshold) rulesBand = 'hardWin';\n\n  scoredItems.push({ json: { ...article, rulesScore, rulesBand, embedText: `${article.title}. ${article.summary}` } });\n}\nreturn scoredItems;"
      },
      "typeVersion": 2
    },
    {
      "id": "eb988b03-aaef-4bda-be7c-a90eb4548130",
      "name": "Filter Rejected Articles",
      "type": "n8n-nodes-base.filter",
      "position": [
        2272,
        80
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "f1",
              "operator": {
                "type": "string",
                "operation": "notEquals"
              },
              "leftValue": "={{ $json.rulesBand }}",
              "rightValue": "reject"
            }
          ]
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "d9d9093d-4dee-4ef8-974c-404ae1cc4c58",
      "name": "Aggregate for Embedding",
      "type": "n8n-nodes-base.aggregate",
      "position": [
        2496,
        80
      ],
      "parameters": {
        "options": {},
        "aggregate": "aggregateAllItemData"
      },
      "typeVersion": 1
    },
    {
      "id": "959210e7-d8cb-47cf-8961-a476de01289f",
      "name": "Select Embedding Provider",
      "type": "n8n-nodes-base.switch",
      "position": [
        2720,
        80
      ],
      "parameters": {
        "rules": {
          "values": [
            {
              "outputKey": "Hugging Face",
              "conditions": {
                "options": {
                  "version": 2,
                  "leftValue": "",
                  "caseSensitive": true,
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "s1",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "leftValue": "={{ $('Set Workflow Context').first().json.embeddingProvider }}",
                    "rightValue": "huggingface"
                  }
                ]
              },
              "renameOutput": true
            },
            {
              "outputKey": "OpenAI",
              "conditions": {
                "options": {
                  "version": 2,
                  "leftValue": "",
                  "caseSensitive": true,
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "s2",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "leftValue": "={{ $('Set Workflow Context').first().json.embeddingProvider }}",
                    "rightValue": "openai"
                  }
                ]
              },
              "renameOutput": true
            }
          ]
        },
        "options": {}
      },
      "typeVersion": 3.2
    },
    {
      "id": "bcd2c569-151c-40f0-ae4b-0e3b874234c6",
      "name": "Post to Hugging Face API",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        2944,
        16
      ],
      "parameters": {
        "url": "={{ 'https://router.huggingface.co/hf-inference/models/' + $('Set Workflow Context').first().json.hfEmbeddingModel + '/pipeline/feature-extraction' }}",
        "method": "POST",
        "options": {
          "response": {
            "response": {
              "fullResponse": true
            }
          }
        },
        "jsonBody": "={{ JSON.stringify({ inputs: $json.data.map(article => article.embedText) }) }}",
        "sendBody": true,
        "specifyBody": "json",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "huggingFaceApi"
      },
      "credentials": {
        "huggingFaceApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "2626a24a-f3a2-48b6-baf4-f1052f2106f9",
      "name": "Post to OpenAI Embeddings",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        2944,
        208
      ],
      "parameters": {
        "url": "https://api.openai.com/v1/embeddings",
        "method": "POST",
        "options": {
          "response": {
            "response": {
              "fullResponse": true
            }
          }
        },
        "jsonBody": "={{ JSON.stringify({ model: $('Set Workflow Context').first().json.openaiEmbeddingModel, input: $json.data.map(article => article.embedText) }) }}",
        "sendBody": true,
        "specifyBody": "json",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "openAiApi"
      },
      "credentials": {
        "openAiApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "d2c9c1d1-ef13-44c9-b8ca-50203ac1e7fe",
      "name": "Set Hugging Face Vectors",
      "type": "n8n-nodes-base.set",
      "position": [
        3168,
        16
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "n1",
              "name": "vectors",
              "type": "array",
              "value": "={{ $json.body }}"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "be511255-f154-481f-8e44-0bb25b3b47c9",
      "name": "Set OpenAI Vectors",
      "type": "n8n-nodes-base.set",
      "position": [
        3168,
        208
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "n2",
              "name": "vectors",
              "type": "array",
              "value": "={{ $json.body.data.map(entry => entry.embedding) }}"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "2c097be8-2772-42f9-9030-697206cf2447",
      "name": "Cluster Similar Stories",
      "type": "n8n-nodes-base.code",
      "position": [
        3392,
        80
      ],
      "parameters": {
        "jsCode": "// Step 2 of 3: group near-duplicate coverage of the same story.\n// Code because similarity clustering needs a pairwise cosine matrix,\n// which no native node expresses. Greedy single-link over 50 articles is instant.\n// One item per STORY leaves this node, so the canvas count itself shows the dedup.\nconst config = $('Set Workflow Context').first().json;\nconst threshold = Number(config.clusterSimilarityThreshold);\nconst articles = $('Filter Rejected Articles').all().map(item => item.json);\nconst vectors = $input.first().json.vectors;\nif (!Array.isArray(vectors) || vectors.length !== articles.length) {\n  throw new Error(`Vector count (${vectors && vectors.length}) does not match article count (${articles.length})`);\n}\n\nfunction cosineSimilarity(vectorA, vectorB) {\n  let dotProduct = 0;\n  let magnitudeA = 0;\n  let magnitudeB = 0;\n  for (let dim = 0; dim < vectorA.length; dim++) {\n    dotProduct += vectorA[dim] * vectorB[dim];\n    magnitudeA += vectorA[dim] * vectorA[dim];\n    magnitudeB += vectorB[dim] * vectorB[dim];\n  }\n  return dotProduct / (Math.sqrt(magnitudeA) * Math.sqrt(magnitudeB));\n}\n\nconst clusters = [];\nfor (let articleIndex = 0; articleIndex < articles.length; articleIndex++) {\n  let joinedExisting = false;\n  for (const cluster of clusters) {\n    if (cluster.some(memberIndex => cosineSimilarity(vectors[articleIndex], vectors[memberIndex]) >= threshold)) {\n      cluster.push(articleIndex);\n      joinedExisting = true;\n      break;\n    }\n  }\n  if (!joinedExisting) clusters.push([articleIndex]);\n}\n\n// The primary is picked deterministically: highest rules score, longer excerpt on ties.\n// Every other member rides along on the primary as corroborating coverage.\nconst storyItems = [];\nclusters.forEach((memberIndexes, clusterNumber) => {\n  const primaryIndex = memberIndexes.reduce((bestIndex, candidateIndex) => {\n    const bestArticle = articles[bestIndex];\n    const candidateArticle = articles[candidateIndex];\n    if (candidateArticle.rulesScore !== bestArticle.rulesScore) {\n      return candidateArticle.rulesScore > bestArticle.rulesScore ? candidateIndex : bestIndex;\n    }\n    return String(candidateArticle.content_excerpt || '').length > String(bestArticle.content_excerpt || '').length\n      ? candidateIndex : bestIndex;\n  });\n  const corroborating = memberIndexes\n    .filter(memberIndex => memberIndex !== primaryIndex)\n    .map(memberIndex => ({\n      title: articles[memberIndex].title,\n      source: articles[memberIndex].source,\n      url: articles[memberIndex].url,\n    }));\n  storyItems.push({ json: {\n    ...articles[primaryIndex],\n    clusterId: clusterNumber + 1,\n    clusterSize: memberIndexes.length,\n    isPrimary: true,\n    corroborating,\n  }});\n});\nreturn storyItems;"
      },
      "typeVersion": 2
    },
    {
      "id": "f3d87989-002a-4226-a122-14fb28c57a12",
      "name": "Check for LLM Judgment",
      "type": "n8n-nodes-base.if",
      "position": [
        3616,
        80
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "i1",
              "operator": {
                "type": "boolean",
                "operation": "equals"
              },
              "leftValue": "={{ $json.isPrimary }}",
              "rightValue": true
            },
            {
              "id": "i2",
              "operator": {
                "type": "string",
                "operation": "notEquals"
              },
              "leftValue": "={{ $json.rulesBand }}",
              "rightValue": "hardWin"
            }
          ]
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "e85bc260-3647-4351-a51e-1ff687232cea",
      "name": "Relevance Judgment",
      "type": "@n8n/n8n-nodes-langchain.chainLlm",
      "position": [
        3888,
        16
      ],
      "parameters": {
        "text": "=You are the relevance judge in a content pipeline.\nNiche: {{ $('Set Workflow Context').first().json.nicheContext }}\n\nArticle:\nTitle: {{ $json.title }}\nSource: {{ $json.source }}\nSummary: {{ $json.summary }}\nExcerpt: {{ $json.content_excerpt }}\n\nDecide whether this article is relevant enough to become client-facing content in this niche. Reject encyclopedia-style evergreen pages, thin social posts with no substance, and anything off-niche.",
        "batching": {
          "batchSize": 10
        },
        "messages": {
          "messageValues": []
        },
        "promptType": "define",
        "hasOutputParser": true
      },
      "typeVersion": 1.9
    },
    {
      "id": "0820d66e-867b-4549-8295-178ff874a6e5",
      "name": "Set OpenAI Judge Model",
      "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
      "position": [
        3888,
        240
      ],
      "parameters": {
        "model": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $('Set Workflow Context').first().json.judgeModel }}"
        },
        "options": {}
      },
      "credentials": {
        "openAiApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "8ff317d3-d417-4083-8873-02344192c034",
      "name": "Parse Judgment Verdict",
      "type": "@n8n/n8n-nodes-langchain.outputParserStructured",
      "position": [
        4096,
        240
      ],
      "parameters": {
        "schemaType": "manual",
        "inputSchema": "{\"type\": \"object\", \"properties\": {\"verdict\": {\"type\": \"string\", \"enum\": [\"approved\", \"rejected\"]}, \"reason\": {\"type\": \"string\", \"description\": \"One sentence explaining the verdict\"}}, \"required\": [\"verdict\", \"reason\"]}"
      },
      "typeVersion": 1.2
    },
    {
      "id": "15b20e32-de78-4f2b-90a2-1e49946f9108",
      "name": "Assemble Approved Articles",
      "type": "n8n-nodes-base.code",
      "position": [
        4496,
        80
      ],
      "parameters": {
        "jsCode": "// The payoff: one row per approved story. Code because this aggregates three\n// sources (story primaries, judge verdicts, run counts) into a single package shape.\nconst storyPrimaries = $('Cluster Similar Stories').all().map(item => item.json);\nconst judgedPrimaries = storyPrimaries.filter(story => story.rulesBand !== 'hardWin');\n\nlet judgeOutputs = [];\ntry {\n  judgeOutputs = $('Relevance Judgment').all().map(item => item.json);\n} catch (noJudgeRun) {\n  judgeOutputs = []; // every story was a hard win; the LLM was never called\n}\n\nconst verdictByCluster = {};\nfor (const story of storyPrimaries) {\n  if (story.rulesBand === 'hardWin') {\n    verdictByCluster[story.clusterId] = { verdict: 'approved', reason: 'Rules hard win - skipped the LLM entirely' };\n  }\n}\njudgedPrimaries.forEach((story, judgeIndex) => {\n  const judgeResult = judgeOutputs[judgeIndex] ? (judgeOutputs[judgeIndex].output || judgeOutputs[judgeIndex]) : null;\n  verdictByCluster[story.clusterId] = {\n    verdict: judgeResult && judgeResult.verdict ? judgeResult.verdict : 'rejected',\n    reason: judgeResult && judgeResult.reason ? judgeResult.reason : 'No judge output received',\n  };\n});\n\nconst articlesIn = $('Read Articles from Sheets').all().length;\nconst survivedRules = $('Filter Rejected Articles').all().length;\n\nconst runId = $execution.id || 'manual';\nconst runAt = new Date().toISOString();\nconst runSignature = `${articlesIn}-${survivedRules}-${storyPrimaries.length}-${judgedPrimaries.length}`;\n\nconst packageRows = [];\nfor (const story of storyPrimaries) {\n  const clusterVerdict = verdictByCluster[story.clusterId] || { verdict: 'rejected', reason: 'Missing verdict' };\n  if (clusterVerdict.verdict !== 'approved') continue;\n  const corroborating = (story.corroborating || [])\n    .map(member => `${member.title} - ${member.source} (${member.url})`)\n    .join(' | ');\n  packageRows.push({ json: {\n    run_id: runId,\n    run_at: runAt,\n    run_signature: runSignature,\n    cluster_id: story.clusterId,\n    best_title: story.title,\n    best_source: story.source,\n    best_url: story.url,\n    rules_score: story.rulesScore,\n    verdict_reason: clusterVerdict.reason,\n    corroborating_sources: corroborating || 'none - single-source story',\n    articles_in_cluster: story.clusterSize,\n    run_articles_in: articlesIn,\n    run_survived_rules: survivedRules,\n    run_unique_stories: storyPrimaries.length,\n    run_llm_calls: judgedPrimaries.length,\n  }});\n}\nreturn packageRows;"
      },
      "typeVersion": 2
    },
    {
      "id": "aabb209a-6e59-4017-a42b-8cb996e50580",
      "name": "Append Approved to Sheets",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        4720,
        16
      ],
      "parameters": {
        "columns": {
          "value": {
            "run_at": "={{ $json.run_at }}",
            "run_id": "={{ $json.run_id }}",
            "best_url": "={{ $json.best_url }}",
            "best_title": "={{ $json.best_title }}",
            "cluster_id": "={{ $json.cluster_id }}",
            "best_source": "={{ $json.best_source }}",
            "rules_score": "={{ $json.rules_score }}",
            "run_llm_calls": "={{ $json.run_llm_calls }}",
            "run_signature": "={{ $json.run_signature }}",
            "verdict_reason": "={{ $json.verdict_reason }}",
            "run_articles_in": "={{ $json.run_articles_in }}",
            "run_survived_rules": "={{ $json.run_survived_rules }}",
            "run_unique_stories": "={{ $json.run_unique_stories }}",
            "articles_in_cluster": "={{ $json.articles_in_cluster }}",
            "corroborating_sources": "={{ $json.corroborating_sources }}"
          },
          "schema": [
            {
              "id": "run_id",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "run_id",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            },
            {
              "id": "run_at",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "run_at",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            },
            {
              "id": "run_signature",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "run_signature",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            },
            {
              "id": "cluster_id",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "cluster_id",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            },
            {
              "id": "best_title",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "best_title",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            },
            {
              "id": "best_source",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "best_source",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            },
            {
              "id": "best_url",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "best_url",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            },
            {
              "id": "rules_score",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "rules_score",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            },
            {
              "id": "verdict_reason",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "verdict_reason",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            },
            {
              "id": "corroborating_sources",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "corroborating_sources",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            },
            {
              "id": "articles_in_cluster",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "articles_in_cluster",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            },
            {
              "id": "run_articles_in",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "run_articles_in",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            },
            {
              "id": "run_survived_rules",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "run_survived_rules",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            },
            {
              "id": "run_unique_stories",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "run_unique_stories",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            },
            {
              "id": "run_llm_calls",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "run_llm_calls",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": []
        },
        "options": {
          "useAppend": true
        },
        "operation": "append",
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "={{ $('Set Workflow Context').first().json.approvedTab }}"
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $('Set Workflow Context').first().json.sheetDocumentId }}"
        }
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.7
    },
    {
      "id": "41eb7e42-b8b5-4c3e-bf41-4b482d3cc429",
      "name": "Check for Live Articles",
      "type": "n8n-nodes-base.if",
      "position": [
        256,
        352
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "lg1",
              "operator": {
                "type": "boolean",
                "operation": "equals"
              },
              "leftValue": "={{ $('Set Workflow Context').first().json.useLiveFetch }}",
              "rightValue": true
            }
          ]
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "86da3f1f-77e8-4e52-95e5-e1784f2a0219",
      "name": "Fetch RSS Feeds",
      "type": "n8n-nodes-base.rssFeedRead",
      "position": [
        704,
        352
      ],
      "parameters": {
        "url": "={{ $json.rssFeedUrls }}",
        "options": {}
      },
      "typeVersion": 1.1
    },
    {
      "id": "51385fe5-bd16-4d92-8dcd-074c9197636f",
      "name": "Combine Sheet and Live Articles",
      "type": "n8n-nodes-base.merge",
      "position": [
        1152,
        -160
      ],
      "parameters": {},
      "typeVersion": 3.2
    },
    {
      "id": "22b0269c-1dfc-4ff7-b023-ae072e225a19",
      "name": "Set Live Article Fields",
      "type": "n8n-nodes-base.set",
      "position": [
        928,
        352
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "r1",
              "name": "id",
              "type": "string",
              "value": "={{ 'live-' + ($itemIndex + 1) }}"
            },
            {
              "id": "r2",
              "name": "title",
              "type": "string",
              "value": "={{ $json.title }}"
            },
            {
              "id": "r3",
              "name": "summary",
              "type": "string",
              "value": "={{ $json.contentSnippet }}"
            },
            {
              "id": "r4",
              "name": "source",
              "type": "string",
              "value": "={{ ($json.link || '').replace(/^https?:\\/\\/(www\\.)?/, '').split('/')[0] }}"
            },
            {
              "id": "r5",
              "name": "url",
              "type": "string",
              "value": "={{ $json.link }}"
            },
            {
              "id": "r6",
              "name": "published_at",
              "type": "string",
              "value": "={{ $json.isoDate }}"
            },
            {
              "id": "r7",
              "name": "content_excerpt",
              "type": "string",
              "value": "={{ $json.contentSnippet }}"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "51393caa-04df-4be7-9aa5-adbabfdd33a3",
      "name": "Merge LLM Judgments",
      "type": "n8n-nodes-base.merge",
      "position": [
        4272,
        80
      ],
      "parameters": {},
      "typeVersion": 3.2
    },
    {
      "id": "6469bd65-bfcd-4102-8fc7-038e2736749e",
      "name": "Check for Fresh Start",
      "type": "n8n-nodes-base.if",
      "position": [
        32,
        16
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "cg1",
              "operator": {
                "type": "boolean",
                "operation": "equals"
              },
              "leftValue": "={{ $('Set Workflow Context').first().json.clearBeforeRun }}",
              "rightValue": true
            }
          ]
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "87ed75f1-16cc-42a3-b852-01d2305c4725",
      "name": "Resume After Clear",
      "type": "n8n-nodes-base.merge",
      "position": [
        704,
        16
      ],
      "parameters": {},
      "typeVersion": 3.2
    },
    {
      "id": "e8fbc2b7-759c-45b1-bf31-6cec2d5b7c4f",
      "name": "Clear Seen Ledger in Sheets",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        480,
        -112
      ],
      "parameters": {
        "operation": "clear",
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "={{ $('Set Workflow Context').first().json.articlesTab }}"
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $('Set Workflow Context').first().json.sheetDocumentId }}"
        },
        "keepFirstRow": true
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.7
    },
    {
      "id": "5dfd931e-e3a3-47b9-b766-ef98c70f2bde",
      "name": "Read Seen Ledger from Sheets",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        1152,
        32
      ],
      "parameters": {
        "options": {},
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "={{ $('Set Workflow Context').first().json.articlesTab }}"
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $('Set Workflow Context').first().json.sheetDocumentId }}"
        }
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.7,
      "alwaysOutputData": true
    },
    {
      "id": "45a5a0dd-fc07-428a-926f-0c71dc8006c6",
      "name": "Remove Duplicate URLs",
      "type": "n8n-nodes-base.merge",
      "position": [
        1376,
        80
      ],
      "parameters": {
        "mode": "combine",
        "options": {},
        "joinMode": "keepNonMatches",
        "outputDataFrom": "input1",
        "fieldsToMatchString": "url"
      },
      "typeVersion": 3.2
    },
    {
      "id": "db28763a-a420-44a7-ba36-8943772936f8",
      "name": "Log to Seen Ledger",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        1824,
        80
      ],
      "parameters": {
        "columns": {
          "value": {
            "id": "={{ $json.id }}",
            "url": "={{ $json.url }}",
            "title": "={{ $json.title }}",
            "reason": "={{ $json.reason }}",
            "run_id": "={{ $json.run_id }}",
            "source": "={{ $json.source }}",
            "status": "={{ $json.status }}",
            "summary": "={{ $json.summary }}",
            "cluster_id": "={{ $json.cluster_id }}",
            "rules_score": "={{ $json.rules_score }}",
            "published_at": "={{ $json.published_at }}",
            "content_excerpt": "={{ $json.content_excerpt }}"
          },
          "schema": [
            {
              "id": "id",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "id",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            },
            {
              "id": "title",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "title",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            },
            {
              "id": "summary",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "summary",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            },
            {
              "id": "source",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "source",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            },
            {
              "id": "url",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "url",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "published_at",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "published_at",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            },
            {
              "id": "content_excerpt",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "content_excerpt",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            },
            {
              "id": "status",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "status",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            },
            {
              "id": "rules_score",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "rules_score",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            },
            {
              "id": "reason",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "reason",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            },
            {
              "id": "cluster_id",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "cluster_id",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            },
            {
              "id": "run_id",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "run_id",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": []
        },
        "options": {
          "useAppend": true
        },
        "operation": "append",
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "={{ $('Set Workflow Context').first().json.articlesTab }}"
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $('Set Workflow Context').first().json.sheetDocumentId }}"
        }
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.7
    },
    {
      "id": "4ed612a6-c350-4134-aa26-df58f572b322",
      "name": "Set Pending Article Status",
      "type": "n8n-nodes-base.set",
      "position": [
        1600,
        80
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "p1",
              "name": "status",
              "type": "string",
              "value": "pending"
            },
            {
              "id": "p2",
              "name": "rules_score",
              "type": "string",
              "value": ""
            },
            {
              "id": "p3",
              "name": "reason",
              "type": "string",
              "value": ""
            },
            {
              "id": "p4",
              "name": "cluster_id",
              "type": "string",
              "value": ""
            },
            {
              "id": "p5",
              "name": "run_id",
              "type": "string",
              "value": "={{ $execution.id }}"
            }
          ]
        },
        "includeOtherFields": true
      },
      "typeVersion": 3.4
    },
    {
      "id": "c6d5d892-e635-4118-a916-b7ad38cbfa17",
      "name": "Compile Article Outcomes",
      "type": "n8n-nodes-base.code",
      "position": [
        4720,
        368
      ],
      "parameters": {
        "jsCode": "// Writes every article's fate back to the seen ledger, matched by URL.\n// Recomputes the same verdict mapping the assembler uses - both nodes read\n// identical sources, so they cannot drift.\nconst scoredArticles = $('Apply Scoring Rules').all().map(item => item.json);\nconst storyPrimaries = $('Cluster Similar Stories').all().map(item => item.json);\nconst judgedPrimaries = storyPrimaries.filter(story => story.rulesBand !== 'hardWin');\nlet judgeOutputs = [];\ntry {\n  judgeOutputs = $('Relevance Judgment').all().map(item => item.json);\n} catch (noJudgeRun) {\n  judgeOutputs = [];\n}\n\nconst config = $('Set Workflow Context').first().json;\nconst runId = String($execution.id || 'manual');\n\nconst verdictByCluster = {};\nfor (const story of storyPrimaries) {\n  if (story.rulesBand === 'hardWin') {\n    verdictByCluster[story.clusterId] = { verdict: 'approved', reason: 'Rules hard win - skipped the LLM entirely' };\n  }\n}\njudgedPrimaries.forEach((story, judgeIndex) => {\n  const judgeResult = judgeOutputs[judgeIndex] ? (judgeOutputs[judgeIndex].output || judgeOutputs[judgeIndex]) : null;\n  verdictByCluster[story.clusterId] = {\n    verdict: judgeResult && judgeResult.verdict ? judgeResult.verdict : 'rejected',\n    reason: judgeResult && judgeResult.reason ? judgeResult.reason : 'No judge output received',\n  };\n});\n\nconst outcomeByUrl = {};\nfor (const article of scoredArticles) {\n  if (article.rulesBand === 'reject') {\n    outcomeByUrl[article.url] = {\n      status: 'rejected_rules',\n      rules_score: article.rulesScore,\n      reason: `Rules score ${article.rulesScore} below reject threshold ${config.rulesRejectThreshold}`,\n      cluster_id: '',\n    };\n  }\n}\nfor (const story of storyPrimaries) {\n  const clusterVerdict = verdictByCluster[story.clusterId] || { verdict: 'rejected', reason: 'Missing verdict' };\n  outcomeByUrl[story.url] = {\n    status: clusterVerdict.verdict === 'approved' ? 'approved' : 'rejected_judge',\n    rules_score: story.rulesScore,\n    reason: clusterVerdict.reason,\n    cluster_id: story.clusterId,\n  };\n  for (const member of story.corroborating || []) {\n    const memberScored = scoredArticles.find(candidate => candidate.url === member.url);\n    outcomeByUrl[member.url] = {\n      status: 'corroborating',\n      rules_score: memberScored ? memberScored.rulesScore : '',\n      reason: `Duplicate coverage of: ${story.title}`,\n      cluster_id: story.clusterId,\n    };\n  }\n}\n\nreturn Object.entries(outcomeByUrl).map(([url, outcome]) => ({ json: { url, ...outcome, run_id: runId } }));"
      },
      "typeVersion": 2
    },
    {
      "id": "c3ceb932-f4cf-4461-a20a-5a4c548c8bc4",
      "name": "Update Ledger Outcomes",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        4944,
        368
      ],
      "parameters": {
        "columns": {
          "value": {
            "url": "={{ $json.url }}",
            "reason": "={{ $json.reason }}",
            "run_id": "={{ $json.run_id }}",
            "status": "={{ $json.status }}",
            "cluster_id": "={{ $json.cluster_id }}",
            "rules_score": "={{ $json.rules_score }}"
          },
          "schema": [
            {
              "id": "url",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "url",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "status",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "status",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            },
            {
              "id": "rules_score",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "rules_score",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            },
            {
              "id": "reason",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "reason",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            },
            {
              "id": "cluster_id",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "cluster_id",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            },
            {
              "id": "run_id",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "run_id",
              "defaultMatch": false,
              "canBeUsedToMatch": false
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": [
            "url"
          ]
        },
        "options": {},
        "operation": "appendOrUpdate",
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "={{ $('Set Workflow Context').first().json.articlesTab }}"
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $('Set Workflow Context').first().json.sheetDocumentId }}"
        }
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.7
    },
    {
      "id": "70e487fc-3392-4532-a3b5-e65c3c18d6b7",
      "name": "Split RSS Feed URLs",
      "type": "n8n-nodes-base.splitOut",
      "position": [
        480,
        352
      ],
      "parameters": {
        "options": {},
        "fieldToSplitOut": "rssFeedUrls"
      },
      "typeVersion": 1
    },
    {
      "id": "69828c23-2118-48f6-ab6a-6116d465e729",
      "name": "Scheduled Midnight Trigger",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        -416,
        208
      ],
      "parameters": {
        "rule": {
          "interval": [
            {}
          ]
        }
      },
      "typeVersion": 1.3
    },
    {
      "id": "dfd0152a-2eb0-42a3-9c2a-970f011fcb30",
      "name": "Sticky howitworks",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1632,
        -784
      ],
      "parameters": {
        "width": 620,
        "height": 1152,
        "content": "# How It Works \ud83e\udde0\nTurns a noisy news feed into a deduplicated, approved content package - spending real money only on stories that earn it.\n\nThree escalating-cost steps:\n1. **Rules tier (free)** - keyword + source scoring kills the obvious noise; articles above the hard-win threshold skip the LLM entirely\n2. **Semantic dedup (pennies)** - embeddings + cosine similarity cluster outlets covering the same story; one primary per cluster proceeds\n3. **LLM judge (paid, and only here)** - the model rules only on unique, ambiguous stories; every clustered duplicate inherits its primary's verdict for free. **The LLM never sees the same story twice.**\n\nOne row per approved story lands in the Approved Package tab: best article, every other outlet's angle as corroborating sources, and the run counts. Every article's fate is written back to the Articles ledger with the reason - and already-seen URLs are dropped before a penny is spent, so repeat runs are free.\n\n## Setup (~5 minutes)\n1. **Credentials** - create Google Sheets (OAuth), Hugging Face (free token) and OpenAI credentials, then attach them to the Sheets, embedding and judge nodes\n2. **Sheet** - copy the starter sheet linked in the template description (File \u2192 Make a copy), or run the starter seed workflow from the GitHub repo\n3. **Configure context** - paste your spreadsheet ID into `sheetDocumentId` and put your feeds in `rssFeedUrls`\n4. **Run** - live headlines flow through the three steps into the Approved Package tab\n\n**Adapt it to your niche** by editing three config values: `nicheContext` (what the judge considers relevant), `rulesKeywords`, and `sourceWeights`.\n\n## Starter Google Sheet: https://docs.google.com/spreadsheets/d/11UAshmcWzrzNZRBqeDSL78r7b6GWxou-UJdxhCXRlUY/edit?usp=sharing\n\n## Data sources\nTwo ways to feed the pipeline:\n- **Live RSS (default)** - every URL in `rssFeedUrls` is fetched each run; an article's `source` is its domain, so key `sourceWeights` by domain (e.g. `techcrunch.com`)\n- **Source Articles tab** - populate it from another workflow (or any tool that writes rows in its seven-column shape: id, title, summary, source, url, published_at, content_excerpt) and the pipeline categorises whatever lands there\n\nThe Articles ledger drops anything already seen, so repeat runs cost nothing."
      },
      "typeVersion": 1
    }
  ],
  "active": true,
  "settings": {
    "binaryMode": "separate",
    "callerPolicy": "workflowsFromSameOwner",
    "timeSavedMode": "fixed",
    "availableInMCP": true,
    "executionOrder": "v1"
  },
  "versionId": "bbbc4b56-c411-4d05-a4fc-ae925a52e1cc",
  "nodeGroups": [],
  "connections": {
    "Fetch RSS Feeds": {
      "main": [
        [
          {
            "node": "Set Live Article Fields",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Log to Seen Ledger": {
      "main": [
        [
          {
            "node": "Apply Scoring Rules",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Relevance Judgment": {
      "main": [
        [
          {
            "node": "Merge LLM Judgments",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Resume After Clear": {
      "main": [
        [
          {
            "node": "Read Articles from Sheets",
            "type": "main",
            "index": 0
          },
          {
            "node": "Read Seen Ledger from Sheets",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Set OpenAI Vectors": {
      "main": [
        [
          {
            "node": "Cluster Similar Stories",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Apply Scoring Rules": {
      "main": [
        [
          {
            "node": "Filter Rejected Articles",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Merge LLM Judgments": {
      "main": [
        [
          {
            "node": "Assemble Approved Articles",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Split RSS Feed URLs": {
      "main": [
        [
          {
            "node": "Fetch RSS Feeds",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Set Workflow Context": {
      "main": [
        [
          {
            "node": "Check for Fresh Start",
            "type": "main",
            "index": 0
          },
          {
            "node": "Check for Live Articles",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check for Fresh Start": {
      "main": [
        [
          {
            "node": "Clear Package Data in Sheets",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Resume After Clear",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Remove Duplicate URLs": {
      "main": [
        [
          {
            "node": "Set Pending Article Status",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check for LLM Judgment": {
      "main": [
        [
          {
            "node": "Relevance Judgment",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Merge LLM Judgments",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Parse Judgment Verdict": {
      "ai_outputParser": [
        [
          {
            "node": "Relevance Judgment",
            "type": "ai_outputParser",
            "index": 0
          }
        ]
      ]
    },
    "Set OpenAI Judge Model": {
      "ai_languageModel": [
        [
          {
            "node": "Relevance Judgment",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Aggregate for Embedding": {
      "main": [
        [
          {
            "node": "Select Embedding Provider",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check for Live Articles": {
      "main": [
        [
          {
            "node": "Split RSS Feed URLs",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Cluster Similar Stories": {
      "main": [
        [
          {
            "node": "Check for LLM Judgment",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Manual Pipeline Trigger": {
      "main": [
        [
          {
            "node": "Set Workflow Context",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Set Live Article Fields": {
      "main": [
        [
          {
            "node": "Combine Sheet and Live Articles",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Compile Article Outcomes": {
      "main": [
        [
          {
            "node": "Update Ledger Outcomes",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Filter Rejected Articles": {
      "main": [
        [
          {
            "node": "Aggregate for Embedding",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Post to Hugging Face API": {
      "main": [
        [
          {
            "node": "Set Hugging Face Vectors",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Set Hugging Face Vectors": {
      "main": [
        [
          {
            "node": "Cluster Similar Stories",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Post to OpenAI Embeddings": {
      "main": [
        [
          {
            "node": "Set OpenAI Vectors",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Articles from Sheets": {
      "main": [
        [
          {
            "node": "Combine Sheet and Live Articles",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Select Embedding Provider": {
      "main": [
        [
          {
            "node": "Post to Hugging Face API",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Post to OpenAI Embeddings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Assemble Approved Articles": {
      "main": [
        [
          {
            "node": "Append Approved to Sheets",
            "type": "main",
            "index": 0
          },
          {
            "node": "Compile Article Outcomes",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Scheduled Midnight Trigger": {
      "main": [
        [
          {
            "node": "Set Workflow Context",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Set Pending Article Status": {
      "main": [
        [
          {
            "node": "Log to Seen Ledger",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Clear Seen Ledger in Sheets": {
      "main": [
        [
          {
            "node": "Resume After Clear",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Clear Package Data in Sheets": {
      "main": [
        [
          {
            "node": "Clear Seen Ledger in Sheets",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Seen Ledger from Sheets": {
      "main": [
        [
          {
            "node": "Remove Duplicate URLs",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Combine Sheet and Live Articles": {
      "main": [
        [
          {
            "node": "Remove Duplicate URLs",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}