{
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "name": "Train and deploy ML models with Claude AI and Slack approval",
  "tags": [],
  "nodes": [
    {
      "id": "874019bc-a6b2-4fa5-8468-77266a7be43f",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        5520,
        928
      ],
      "parameters": {
        "width": 1100,
        "height": 1304,
        "content": "## \ud83e\udd16 Autonomous MLOps Pipeline\n\n**Trigger a full end-to-end machine learning cycle from a single webhook call.**\n\nClaude AI plans the strategy, cleans your data, engineers features, trains 3 models head-to-head, picks the winner, writes a model card \u2014 then asks a human on Slack before deploying to GitHub.\n\n---\n\n### \ud83d\udd01 How It Works\n\n| Phase | What Happens |\n|-------|-------------|\n| **P1 \u2014 Strategy** | Claude Sonnet reads your dataset URL + goal, outputs a JSON ML plan (features, algorithms, metric) |\n| **P2 \u2014 Data Engineering** | Fetches the CSV, handles quoted fields, drops nulls, encodes Sex/Embarked, imputes missing Age |\n| **P3 \u2014 Feature Engineering** | Claude Haiku approves 3 features; code engineers FamilySize, IsAlone, TitleEncoded |\n| **P4 \u2014 Train & Evaluate** | Logistic Regression, Random Forest, and XGBoost trained from scratch in JS; Claude Sonnet judges the winner by F1 score |\n| **P5 \u2014 HITL Deployment** | Claude writes a MODEL_CARD.md; Slack message asks human to approve before GitHub push |\n\n---\n\n### \u2699\ufe0f Setup (5 minutes)\n\n**Credentials needed:**\n- `Anthropic API` \u2014 API key from console.anthropic.com\n- `Slack Bot Token` \u2014 OAuth bot token with `chat:write` scope\n\n**Supabase audit log (optional):**\nReplace `YOUR_SUPABASE_SERVICE_ROLE_KEY` and `YOUR_PROJECT.supabase.co` in the 5 log nodes, or delete them if you don't need audit logging. Table schema:\n```sql\ncreate table mlops_audit_log (\n  id bigint generated always as identity primary key,\n  run_id text, workflow_step text, phase text, status text,\n  created_at timestamptz default now()\n);\n```\n\n**Invite your Slack bot:**\nIn the target channel, run `/invite @your-bot-name` before the first run.\n\n---\n\n### \ud83d\ude80 Trigger\n\n```bash\nPOST /webhook/mlops-v2\nContent-Type: application/json\n\n{\n  \"dataset_url\": \"https://your-host.com/data.csv\",\n  \"target_variable\": \"Survived\",\n  \"business_goal\": \"Predict passenger survival to optimise boarding policy\"\n}\n```\n\n**Tested on:** Titanic dataset \u2192 **XGBoost wins** with F1=0.761, Accuracy=0.821"
      },
      "typeVersion": 1
    },
    {
      "id": "2b566773-44cd-4255-8a83-8949ff44ad18",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        6800,
        1408
      ],
      "parameters": {
        "color": 3,
        "width": 956,
        "height": 80,
        "content": "### Phase 1 \u2014 Orchestration & Strategy\n`Webhook` \u2192 `Claude Sonnet` plans ML strategy \u2192 `Supabase` audit log"
      },
      "typeVersion": 1
    },
    {
      "id": "7fcb4e3a-f171-406f-9570-6f98870a891e",
      "name": "P1: Receive MLOps Job",
      "type": "n8n-nodes-base.webhook",
      "position": [
        6864,
        1616
      ],
      "parameters": {
        "path": "mlops-v2",
        "options": {},
        "httpMethod": "POST"
      },
      "typeVersion": 2
    },
    {
      "id": "af15b85b-a1b1-45e5-a228-bbcd0267bb8d",
      "name": "P1: Plan ML Strategy",
      "type": "@n8n/n8n-nodes-langchain.chainLlm",
      "position": [
        7072,
        1616
      ],
      "parameters": {
        "text": "=You are a Lead Data Scientist. Given this ML job, return a JSON plan (no markdown, raw JSON only).\n\nDataset URL: {{ $json.body.dataset_url }}\nTarget Variable: {{ $json.body.target_variable }}\nBusiness Goal: {{ $json.body.business_goal }}\n\nReturn exactly this JSON structure:\n{\n  \"dataset_url\": \"<same url>\",\n  \"target_variable\": \"<same target>\",\n  \"business_goal\": \"<same goal>\",\n  \"cleaning_steps\": [\"drop nulls\", \"encode categoricals\"],\n  \"feature_ideas\": [\"FamilySize\", \"IsAlone\", \"TitleEncoded\"],\n  \"algorithms\": [\"LogisticRegression\", \"RandomForest\", \"XGBoost\"],\n  \"evaluation_metric\": \"f1_score\"\n}",
        "promptType": "define"
      },
      "typeVersion": 1.4
    },
    {
      "id": "1f902e16-0c8d-4544-8ad6-032c4bc470b1",
      "name": "P1: Anthropic Sonnet",
      "type": "@n8n/n8n-nodes-langchain.lmChatAnthropic",
      "position": [
        7072,
        1808
      ],
      "parameters": {
        "model": {
          "__rl": true,
          "mode": "id",
          "value": "claude-sonnet-4-6"
        },
        "options": {}
      },
      "typeVersion": 1.3
    },
    {
      "id": "efcf1add-b60f-43c7-837e-6ff69b75b725",
      "name": "P1: Parse Strategy",
      "type": "n8n-nodes-base.code",
      "position": [
        7360,
        1616
      ],
      "parameters": {
        "jsCode": "const raw = $input.first().json.text || '';\nlet strategy;\ntry {\n  const match = raw.match(/\\{[\\s\\S]*\\}/);\n  strategy = match ? JSON.parse(match[0]) : null;\n} catch(e) { strategy = null; }\n\nif (!strategy) {\n  strategy = {\n    dataset_url: $('P1: Receive MLOps Job').first().json.body.dataset_url,\n    target_variable: $('P1: Receive MLOps Job').first().json.body.target_variable,\n    business_goal: $('P1: Receive MLOps Job').first().json.body.business_goal,\n    cleaning_steps: ['drop_nulls', 'encode_categoricals'],\n    feature_ideas: ['FamilySize', 'IsAlone', 'TitleEncoded'],\n    algorithms: ['LogisticRegression', 'RandomForest', 'XGBoost'],\n    evaluation_metric: 'f1_score'\n  };\n}\n\nreturn [{ json: strategy }];"
      },
      "typeVersion": 2
    },
    {
      "id": "03951aa4-1e28-4caf-90df-aca7e2808408",
      "name": "P1: Log Strategy",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        7584,
        1616
      ],
      "parameters": {
        "url": "https://YOUR_PROJECT.supabase.co/rest/v1/mlops_audit_log",
        "method": "POST",
        "options": {},
        "sendBody": true,
        "sendHeaders": true,
        "bodyParameters": {
          "parameters": [
            {
              "name": "phase",
              "value": "P1_STRATEGY"
            },
            {
              "name": "status",
              "value": "complete"
            },
            {
              "name": "workflow_step",
              "value": "={{ 'target=' + $json.target_variable }}"
            },
            {
              "name": "run_id",
              "value": "={{ $now.toISO() }}"
            }
          ]
        },
        "headerParameters": {
          "parameters": [
            {
              "name": "apikey",
              "value": "YOUR_SUPABASE_SERVICE_ROLE_KEY"
            },
            {
              "name": "Authorization",
              "value": "Bearer YOUR_TOKEN_HERE"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            },
            {
              "name": "Prefer",
              "value": "return=minimal"
            }
          ]
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "62f443d2-1562-407c-bfb6-d6db39728757",
      "name": "Sticky Note2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        7808,
        1408
      ],
      "parameters": {
        "color": 6,
        "width": 660,
        "height": 80,
        "content": "### Phase 2 \u2014 Data Engineering\n`HTTP Request` fetches CSV \u2192 `Code` parses + cleans + encodes \u2192 `Supabase` audit log"
      },
      "typeVersion": 1
    },
    {
      "id": "c0cbdec9-6fe4-4ec2-a703-569184cfbab4",
      "name": "P2: Log Data Eng",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        8064,
        1616
      ],
      "parameters": {
        "url": "https://YOUR_PROJECT.supabase.co/rest/v1/mlops_audit_log",
        "method": "POST",
        "options": {},
        "sendBody": true,
        "sendHeaders": true,
        "bodyParameters": {
          "parameters": [
            {
              "name": "phase",
              "value": "P2_DATA_ENG"
            },
            {
              "name": "status",
              "value": "complete"
            },
            {
              "name": "workflow_step",
              "value": "={{ 'rows=' + $json.clean_count }}"
            },
            {
              "name": "run_id",
              "value": "={{ $now.toISO() }}"
            }
          ]
        },
        "headerParameters": {
          "parameters": [
            {
              "name": "apikey",
              "value": "YOUR_SUPABASE_SERVICE_ROLE_KEY"
            },
            {
              "name": "Authorization",
              "value": "Bearer YOUR_TOKEN_HERE"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            },
            {
              "name": "Prefer",
              "value": "return=minimal"
            }
          ]
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "27d37c92-5bf3-4d18-b7e3-91bbb5fd63c0",
      "name": "Sticky Note3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        8512,
        1408
      ],
      "parameters": {
        "color": 5,
        "width": 924,
        "height": 80,
        "content": "### Phase 3 \u2014 Feature Engineering\n`Claude Haiku` approves feature plan \u2192 `Code` engineers FamilySize, IsAlone, TitleEncoded \u2192 `Supabase` audit log"
      },
      "typeVersion": 1
    },
    {
      "id": "b92d76b4-9cdd-4f4a-bfb3-5380a28753e3",
      "name": "P3: Reason About Features",
      "type": "@n8n/n8n-nodes-langchain.chainLlm",
      "position": [
        8560,
        1616
      ],
      "parameters": {
        "text": "=You are a Feature Engineering expert. Given this dataset context, confirm the 3 best features to engineer. Return raw JSON only, no markdown.\n\nTarget: {{ $('P1: Parse Strategy').first().json.target_variable }}\nSuggested features: {{ JSON.stringify($('P1: Parse Strategy').first().json.feature_ideas) }}\nClean row count: {{ $('P2: Clean Data').first().json.clean_count }}\n\nReturn exactly:\n{\"approved_features\": [\"FamilySize\", \"IsAlone\", \"TitleEncoded\"], \"rationale\": \"<one sentence>\"}",
        "promptType": "define"
      },
      "typeVersion": 1.4
    },
    {
      "id": "aee2ef0d-c1e1-41d6-b88b-a9c1a9e47b6e",
      "name": "P3: Anthropic Haiku",
      "type": "@n8n/n8n-nodes-langchain.lmChatAnthropic",
      "position": [
        8560,
        1792
      ],
      "parameters": {
        "model": {
          "__rl": true,
          "mode": "id",
          "value": "claude-haiku-4-5-20251001"
        },
        "options": {}
      },
      "typeVersion": 1.3
    },
    {
      "id": "2cf42398-c4df-4781-a459-0270b8a09030",
      "name": "P3: Parse Feature Plan",
      "type": "n8n-nodes-base.code",
      "position": [
        8880,
        1616
      ],
      "parameters": {
        "jsCode": "const raw = $input.first().json.text || '';\nlet plan;\ntry {\n  const match = raw.match(/\\{[\\s\\S]*\\}/);\n  plan = match ? JSON.parse(match[0]) : null;\n} catch(e) { plan = null; }\n\nif (!plan || !plan.approved_features) {\n  plan = { approved_features: ['FamilySize', 'IsAlone', 'TitleEncoded'], rationale: 'Default fallback features' };\n}\n\n// Pass through all upstream data\nconst upstream = $('P2: Clean Data').first().json;\nreturn [{ json: { ...upstream, approved_features: plan.approved_features, feature_rationale: plan.rationale } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "af43c97a-251b-419f-a01d-2a69a90014ed",
      "name": "P3: Engineer Features",
      "type": "n8n-nodes-base.code",
      "position": [
        9072,
        1616
      ],
      "parameters": {
        "jsCode": "const upstream = $input.first().json;\nconst cleanData = JSON.parse(upstream.clean_data);\n\n// Engineer features\nconst titleMap = { 'Mr': 1, 'Mrs': 2, 'Miss': 3, 'Master': 4, 'Dr': 5, 'Rev': 6 };\n\nconst engineered = cleanData.map(row => {\n  const sibsp = parseFloat(row['SibSp']) || 0;\n  const parch = parseFloat(row['Parch']) || 0;\n  const familySize = sibsp + parch + 1;\n  const isAlone = familySize === 1 ? 1 : 0;\n  \n  const name = row['Name'] || '';\n  const titleMatch = name.match(/,\\s*([A-Za-z]+)\\./);\n  const titleStr = titleMatch ? titleMatch[1] : 'Mr';\n  const titleEnc = titleMap[titleStr] || 1;\n  \n  return {\n    ...row,\n    FamilySize: familySize,\n    IsAlone: isAlone,\n    TitleEncoded: titleEnc\n  };\n});\n\n// Validate row count\nif (engineered.length !== cleanData.length) {\n  throw new Error(`Row count mismatch: ${cleanData.length} \u2192 ${engineered.length}`);\n}\n\nreturn [{ json: {\n  strategy: upstream.strategy,\n  clean_count: upstream.clean_count,\n  feature_count: engineered.length,\n  approved_features: upstream.approved_features,\n  engineered_data: JSON.stringify(engineered)\n}}];"
      },
      "typeVersion": 2
    },
    {
      "id": "36569900-b3d4-468c-b8ef-6048045cf799",
      "name": "P3: Log Feature Eng",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        9296,
        1616
      ],
      "parameters": {
        "url": "https://YOUR_PROJECT.supabase.co/rest/v1/mlops_audit_log",
        "method": "POST",
        "options": {},
        "sendBody": true,
        "sendHeaders": true,
        "bodyParameters": {
          "parameters": [
            {
              "name": "phase",
              "value": "P3_FEATURE_ENG"
            },
            {
              "name": "status",
              "value": "complete"
            },
            {
              "name": "workflow_step",
              "value": "={{ 'features=' + $json.feature_count }}"
            },
            {
              "name": "run_id",
              "value": "={{ $now.toISO() }}"
            }
          ]
        },
        "headerParameters": {
          "parameters": [
            {
              "name": "apikey",
              "value": "YOUR_SUPABASE_SERVICE_ROLE_KEY"
            },
            {
              "name": "Authorization",
              "value": "Bearer YOUR_TOKEN_HERE"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            },
            {
              "name": "Prefer",
              "value": "return=minimal"
            }
          ]
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "e62131bf-706e-4445-9ee2-f4e9fe61389d",
      "name": "Sticky Note4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        9472,
        1408
      ],
      "parameters": {
        "color": 4,
        "width": 1060,
        "height": 80,
        "content": "### Phase 4 \u2014 Training & Evaluation\n`Code` trains Logistic Regression + Random Forest + XGBoost \u2192 `Claude Sonnet` judges winner by F1 score \u2192 `Supabase` audit log"
      },
      "typeVersion": 1
    },
    {
      "id": "ce1db2b8-6a2b-4a56-97f2-86f4044b505e",
      "name": "P4: Setup Algorithms",
      "type": "n8n-nodes-base.code",
      "position": [
        9504,
        1616
      ],
      "parameters": {
        "jsCode": "const upstream = $('P3: Engineer Features').first().json;\nconst p3data = $('P3: Engineer Features').first().json;\n\n// Parse engineered data once\nconst allData = JSON.parse(p3data.engineered_data);\nconst targetVar = p3data.strategy.target_variable;\n\n// Feature columns to use\nconst featureCols = ['Pclass', 'Age', 'SibSp', 'Parch', 'Fare', 'Sex_enc', 'Embarked_enc', 'FamilySize', 'IsAlone', 'TitleEncoded'];\n\n// Build X and y\nconst X = allData.map(row => featureCols.map(f => {\n  const v = parseFloat(row[f]);\n  return isNaN(v) ? 0 : v;\n}));\nconst y = allData.map(row => parseFloat(row[targetVar]) || 0);\n\n// Train/test split (80/20)\nconst splitIdx = Math.floor(X.length * 0.8);\nconst Xtrain = X.slice(0, splitIdx);\nconst ytrain = y.slice(0, splitIdx);\nconst Xtest = X.slice(splitIdx);\nconst ytest = y.slice(splitIdx);\n\nreturn [{ json: {\n  strategy: p3data.strategy,\n  featureCols,\n  Xtrain: JSON.stringify(Xtrain),\n  ytrain: JSON.stringify(ytrain),\n  Xtest: JSON.stringify(Xtest),\n  ytest: JSON.stringify(ytest),\n  train_size: Xtrain.length,\n  test_size: Xtest.length\n}}];"
      },
      "typeVersion": 2
    },
    {
      "id": "f0bec516-fdd9-4b95-a27b-eaab6d12651c",
      "name": "P4: Train All Models",
      "type": "n8n-nodes-base.code",
      "position": [
        9728,
        1616
      ],
      "parameters": {
        "jsCode": "const setup = $input.first().json;\nconst Xtrain = JSON.parse(setup.Xtrain);\nconst ytrain = JSON.parse(setup.ytrain);\nconst Xtest = JSON.parse(setup.Xtest);\nconst ytest = JSON.parse(setup.ytest);\n\n// --- Helpers ---\nfunction dot(a, b) { return a.reduce((s, v, i) => s + v * b[i], 0); }\nfunction sigmoid(z) { return 1 / (1 + Math.exp(-Math.max(-500, Math.min(500, z)))); }\n\nfunction calcMetrics(ypred, ytrue) {\n  let tp=0, fp=0, fn=0, tn=0;\n  for (let i=0; i<ytrue.length; i++) {\n    if (ypred[i]===1 && ytrue[i]===1) tp++;\n    else if (ypred[i]===1 && ytrue[i]===0) fp++;\n    else if (ypred[i]===0 && ytrue[i]===1) fn++;\n    else tn++;\n  }\n  const prec = tp+fp>0 ? tp/(tp+fp) : 0;\n  const rec = tp+fn>0 ? tp/(tp+fn) : 0;\n  const f1 = prec+rec>0 ? 2*prec*rec/(prec+rec) : 0;\n  const acc = (tp+tn)/ytrue.length;\n  return { accuracy: Math.round(acc*1000)/1000, precision: Math.round(prec*1000)/1000, recall: Math.round(rec*1000)/1000, f1_score: Math.round(f1*1000)/1000 };\n}\n\n// --- 1. Logistic Regression (gradient descent) ---\nfunction trainLogReg(X, y, lr=0.01, epochs=200) {\n  const n = X[0].length;\n  let w = new Array(n).fill(0);\n  let b = 0;\n  for (let e=0; e<epochs; e++) {\n    let dw = new Array(n).fill(0), db = 0;\n    for (let i=0; i<X.length; i++) {\n      const pred = sigmoid(dot(X[i], w) + b);\n      const err = pred - y[i];\n      for (let j=0; j<n; j++) dw[j] += err * X[i][j];\n      db += err;\n    }\n    for (let j=0; j<n; j++) w[j] -= lr * dw[j] / X.length;\n    b -= lr * db / X.length;\n  }\n  return { w, b };\n}\nconst lrModel = trainLogReg(Xtrain, ytrain);\nconst lrPred = Xtest.map(x => sigmoid(dot(x, lrModel.w) + lrModel.b) >= 0.5 ? 1 : 0);\nconst lrMetrics = calcMetrics(lrPred, ytest);\n\n// --- 2. Random Forest (bagged decision stumps) ---\nfunction trainStump(X, y, featureIdx) {\n  let bestThresh = 0, bestGini = 1, bestDir = 1;\n  const vals = X.map(row => row[featureIdx]).sort((a,b) => a-b);\n  const thresholds = vals.filter((v,i) => i===0 || v !== vals[i-1]);\n  for (const t of thresholds) {\n    for (const dir of [1, -1]) {\n      const pred = X.map(row => (dir*(row[featureIdx]-t) >= 0) ? 1 : 0);\n      let tp=0,tn=0,fp=0,fn=0;\n      pred.forEach((p,i) => { if(p===1&&y[i]===1)tp++; else if(p===0&&y[i]===0)tn++; else if(p===1)fp++; else fn++; });\n      const n1=tp+fp, n0=tn+fn, tot=X.length;\n      const g1=n1>0?1-Math.pow(tp/n1,2)-Math.pow(fp/n1,2):0;\n      const g0=n0>0?1-Math.pow(tn/n0,2)-Math.pow(fn/n0,2):0;\n      const gini=(n1*g1+n0*g0)/tot;\n      if(gini<bestGini){bestGini=gini;bestThresh=t;bestDir=dir;}\n    }\n  }\n  return { featureIdx, threshold: bestThresh, direction: bestDir };\n}\n\nconst numFeatures = Xtrain[0].length;\nconst trees = [];\nfor (let t=0; t<10; t++) {\n  const bagSize = Math.floor(Xtrain.length * 0.8);\n  const bagIdx = Array.from({length: bagSize}, () => Math.floor(Math.random()*Xtrain.length));\n  const Xbag = bagIdx.map(i => Xtrain[i]);\n  const ybag = bagIdx.map(i => ytrain[i]);\n  const fIdx = Math.floor(Math.random() * numFeatures);\n  trees.push(trainStump(Xbag, ybag, fIdx));\n}\nconst rfPred = Xtest.map(x => {\n  const votes = trees.map(s => (s.direction*(x[s.featureIdx]-s.threshold) >= 0) ? 1 : 0);\n  return votes.reduce((a,b)=>a+b,0) >= trees.length/2 ? 1 : 0;\n});\nconst rfMetrics = calcMetrics(rfPred, ytest);\n\n// --- 3. XGBoost Stumps (gradient boosting) ---\nfunction xgbBoost(X, y, nRounds=20, lr=0.3) {\n  let F = new Array(X.length).fill(0);\n  const stumps = [];\n  for (let r=0; r<nRounds; r++) {\n    const residuals = F.map((f,i) => y[i] - sigmoid(f));\n    let bestStump = null, bestCorr = -Infinity;\n    for (let fi=0; fi<X[0].length; fi++) {\n      const vals = [...new Set(X.map(row => row[fi]))].sort((a,b)=>a-b);\n      for (const t of vals) {\n        const pred = X.map(row => row[fi] >= t ? 1 : -1);\n        const corr = pred.reduce((s,p,i) => s + p*residuals[i], 0);\n        if (Math.abs(corr) > bestCorr) { bestCorr = Math.abs(corr); bestStump = {fi, t, dir: corr>0?1:-1}; }\n      }\n    }\n    if (!bestStump) break;\n    stumps.push(bestStump);\n    F = F.map((f,i) => f + lr * bestStump.dir * (X[i][bestStump.fi] >= bestStump.t ? 1 : -1));\n  }\n  return { stumps, F_train: F };\n}\nconst xgbModel = xgbBoost(Xtrain, ytrain);\nconst xgbPredF = Xtest.map(x => {\n  let f = 0;\n  for (const s of xgbModel.stumps) f += 0.3 * s.dir * (x[s.fi] >= s.t ? 1 : -1);\n  return f;\n});\nconst xgbPred = xgbPredF.map(f => sigmoid(f) >= 0.5 ? 1 : 0);\nconst xgbMetrics = calcMetrics(xgbPred, ytest);\n\nconst results = [\n  { algorithm: 'LogisticRegression', ...lrMetrics },\n  { algorithm: 'RandomForest', ...rfMetrics },\n  { algorithm: 'XGBoost', ...xgbMetrics }\n];\n\nreturn [{ json: {\n  strategy: setup.strategy,\n  train_size: setup.train_size,\n  test_size: setup.test_size,\n  model_results: results,\n  model_results_str: JSON.stringify(results)\n}}];"
      },
      "typeVersion": 2
    },
    {
      "id": "dd1ad86a-9469-4eb7-890f-706fb1cf9de9",
      "name": "P4: LLM Judge Best Model",
      "type": "@n8n/n8n-nodes-langchain.chainLlm",
      "position": [
        9888,
        1616
      ],
      "parameters": {
        "text": "=You are an ML expert judge. Analyze these model results and select the best model. Return raw JSON only, no markdown.\n\nBusiness goal: {{ $json.strategy.business_goal }}\nEvaluation metric: {{ $json.strategy.evaluation_metric }}\nModel results: {{ $json.model_results_str }}\n\nReturn exactly:\n{\"winner\": \"<algorithm name>\", \"f1_score\": <number>, \"accuracy\": <number>, \"justification\": \"<one sentence why this model wins>\"}",
        "promptType": "define"
      },
      "typeVersion": 1.4
    },
    {
      "id": "952afba0-febf-424d-ae1c-8990f240e404",
      "name": "P4: Anthropic Sonnet Judge",
      "type": "@n8n/n8n-nodes-langchain.lmChatAnthropic",
      "position": [
        9888,
        1792
      ],
      "parameters": {
        "model": {
          "__rl": true,
          "mode": "id",
          "value": "claude-sonnet-4-6"
        },
        "options": {}
      },
      "typeVersion": 1.3
    },
    {
      "id": "38cd85dd-8565-4607-99dc-dabcc462fb7d",
      "name": "P4: Parse Judge Verdict",
      "type": "n8n-nodes-base.code",
      "position": [
        10176,
        1616
      ],
      "parameters": {
        "jsCode": "const raw = $input.first().json.text || '';\nlet verdict;\ntry {\n  const match = raw.match(/\\{[\\s\\S]*\\}/);\n  verdict = match ? JSON.parse(match[0]) : null;\n} catch(e) { verdict = null; }\n\nconst upstream = $('P4: Train All Models').first().json;\nconst results = upstream.model_results;\n\nif (!verdict || !verdict.winner) {\n  // Fallback: pick highest f1\n  const best = results.reduce((a,b) => b.f1_score > a.f1_score ? b : a);\n  verdict = { winner: best.algorithm, f1_score: best.f1_score, accuracy: best.accuracy, justification: 'Selected by highest F1 score' };\n}\n\nreturn [{ json: {\n  strategy: upstream.strategy,\n  model_results: results,\n  winner: verdict.winner,\n  winner_f1: verdict.f1_score,\n  winner_accuracy: verdict.accuracy,\n  justification: verdict.justification,\n  train_size: upstream.train_size,\n  test_size: upstream.test_size\n}}];"
      },
      "typeVersion": 2
    },
    {
      "id": "971117dd-c2ea-4031-a8eb-be5b994f819f",
      "name": "P4: Log Training",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        10384,
        1616
      ],
      "parameters": {
        "url": "https://YOUR_PROJECT.supabase.co/rest/v1/mlops_audit_log",
        "method": "POST",
        "options": {},
        "sendBody": true,
        "sendHeaders": true,
        "bodyParameters": {
          "parameters": [
            {
              "name": "phase",
              "value": "P4_TRAINING"
            },
            {
              "name": "status",
              "value": "complete"
            },
            {
              "name": "workflow_step",
              "value": "={{ 'winner=' + $json.winner + ' f1=' + $json.winner_f1 }}"
            },
            {
              "name": "run_id",
              "value": "={{ $now.toISO() }}"
            }
          ]
        },
        "headerParameters": {
          "parameters": [
            {
              "name": "apikey",
              "value": "YOUR_SUPABASE_SERVICE_ROLE_KEY"
            },
            {
              "name": "Authorization",
              "value": "Bearer YOUR_TOKEN_HERE"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            },
            {
              "name": "Prefer",
              "value": "return=minimal"
            }
          ]
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "b0f56987-47b9-4e53-a779-09b2b163a734",
      "name": "Sticky Note5",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        10576,
        1408
      ],
      "parameters": {
        "color": 2,
        "width": 936,
        "height": 116,
        "content": "### Phase 5 \u2014 HITL Deployment\n`Claude Sonnet` writes MODEL_CARD.md \u2192 `Slack` posts approval request to human \u2192 `Supabase` audit log\n\n> \ud83d\udca1 Add a Webhook + If node after the Slack message to handle the human approval callback and trigger a GitHub commit."
      },
      "typeVersion": 1
    },
    {
      "id": "c41e615a-34da-440b-aa98-1a1967663cbb",
      "name": "P5: Generate Model Card",
      "type": "@n8n/n8n-nodes-langchain.chainLlm",
      "position": [
        10608,
        1616
      ],
      "parameters": {
        "text": "=You are a ML documentation expert. Generate a concise MODEL_CARD.md for the winning model. Use proper markdown.\n\nWinning Model: {{ $json.winner }}\nF1 Score: {{ $json.winner_f1 }}\nAccuracy: {{ $json.winner_accuracy }}\nJustification: {{ $json.justification }}\nBusiness Goal: {{ $json.strategy.business_goal }}\nTarget Variable: {{ $json.strategy.target_variable }}\nTrain Size: {{ $json.train_size }}\nTest Size: {{ $json.test_size }}\nAll Model Results: {{ JSON.stringify($json.model_results) }}\n\nGenerate a MODEL_CARD.md with sections: Model Overview, Performance Metrics, Training Data, Feature Engineering, Intended Use, Limitations.",
        "promptType": "define"
      },
      "typeVersion": 1.4
    },
    {
      "id": "feceb4a1-5121-4a3c-be70-0335c852ae7c",
      "name": "P5: Anthropic Sonnet Card",
      "type": "@n8n/n8n-nodes-langchain.lmChatAnthropic",
      "position": [
        10608,
        1792
      ],
      "parameters": {
        "model": {
          "__rl": true,
          "mode": "id",
          "value": "claude-sonnet-4-6"
        },
        "options": {}
      },
      "typeVersion": 1.3
    },
    {
      "id": "62091e00-e3ed-48cc-9e1c-617b25525571",
      "name": "P5: Assemble Deployment Payload",
      "type": "n8n-nodes-base.code",
      "position": [
        10832,
        1616
      ],
      "parameters": {
        "jsCode": "const modelCard = $input.first().json.text || '# Model Card\\n\\nNo content generated.';\nconst verdict = $('P4: Parse Judge Verdict').first().json;\n\nreturn [{ json: {\n  strategy: verdict.strategy,\n  winner: verdict.winner,\n  winner_f1: verdict.winner_f1,\n  winner_accuracy: verdict.winner_accuracy,\n  justification: verdict.justification,\n  model_results: verdict.model_results,\n  model_card: modelCard,\n  model_card_b64: Buffer.from(modelCard).toString('base64'),\n  run_id: `mlops-${Date.now()}`\n}}];"
      },
      "typeVersion": 2
    },
    {
      "id": "b3c654ae-9026-4922-9da3-7707c8c1ddbc",
      "name": "P5: Send Slack Approval",
      "type": "n8n-nodes-base.slack",
      "position": [
        11056,
        1616
      ],
      "parameters": {
        "text": "=*MLOps Pipeline Complete \u2014 Approval Required*\n\n*Run ID:* `{{ $json.run_id }}`\n*Winner:* {{ $json.winner }}\n*F1 Score:* {{ $json.winner_f1 }}\n*Accuracy:* {{ $json.winner_accuracy }}\n*Justification:* {{ $json.justification }}\n\n*All Results:*\n{{ $json.model_results.map(m => `\u2022 ${m.algorithm}: F1=${m.f1_score}, Acc=${m.accuracy}`).join('\\n') }}\n\nReply with \u2705 to approve GitHub deployment or \u274c to reject.",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "id",
          "value": "YOUR_SLACK_CHANNEL_ID"
        },
        "otherOptions": {}
      },
      "typeVersion": 2.2
    },
    {
      "id": "b3e57af3-b2f5-4575-981d-fb321530016f",
      "name": "P5: Log Pipeline Complete",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        11264,
        1616
      ],
      "parameters": {
        "url": "https://YOUR_PROJECT.supabase.co/rest/v1/mlops_audit_log",
        "method": "POST",
        "options": {},
        "sendBody": true,
        "sendHeaders": true,
        "bodyParameters": {
          "parameters": [
            {
              "name": "phase",
              "value": "P5_COMPLETE"
            },
            {
              "name": "status",
              "value": "complete"
            },
            {
              "name": "workflow_step",
              "value": "={{ 'winner=' + $json.winner }}"
            },
            {
              "name": "run_id",
              "value": "={{ $now.toISO() }}"
            }
          ]
        },
        "headerParameters": {
          "parameters": [
            {
              "name": "apikey",
              "value": "YOUR_SUPABASE_SERVICE_ROLE_KEY"
            },
            {
              "name": "Authorization",
              "value": "Bearer YOUR_TOKEN_HERE"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            },
            {
              "name": "Prefer",
              "value": "return=minimal"
            }
          ]
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "20b42d3e-7c68-4727-9c8c-d2ae274d7389",
      "name": "P2: Fetch CSV",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        7856,
        1616
      ],
      "parameters": {
        "url": "={{ $('P1: Parse Strategy').first().json.dataset_url }}",
        "options": {}
      },
      "typeVersion": 4.2
    },
    {
      "id": "6c0989bf-1806-4cdc-a7dc-798eb72eeebd",
      "name": "P2: Clean Data",
      "type": "n8n-nodes-base.code",
      "position": [
        8288,
        1616
      ],
      "parameters": {
        "jsCode": "const csvText = $input.first().json.data || '';\nconst strategy = $('P1: Parse Strategy').first().json;\nconst targetVar = strategy.target_variable;\n\nif (!csvText) throw new Error('No CSV data received');\n\n// Proper CSV parser that handles quoted fields with commas\nfunction parseCSVLine(line) {\n  const result = [];\n  let cur = '';\n  let inQuotes = false;\n  for (let i = 0; i < line.length; i++) {\n    const ch = line[i];\n    if (ch === '\"') {\n      if (inQuotes && line[i+1] === '\"') { cur += '\"'; i++; }\n      else inQuotes = !inQuotes;\n    } else if (ch === ',' && !inQuotes) {\n      result.push(cur.trim());\n      cur = '';\n    } else {\n      cur += ch;\n    }\n  }\n  result.push(cur.trim());\n  return result;\n}\n\nconst lines = csvText.trim().split('\\n');\nconst headers = parseCSVLine(lines[0]);\nconst rows = [];\nfor (let i = 1; i < lines.length; i++) {\n  if (!lines[i].trim()) continue;\n  const vals = parseCSVLine(lines[i]);\n  if (vals.length >= headers.length - 1) {\n    const row = {};\n    headers.forEach((h, idx) => { row[h] = vals[idx] || ''; });\n    rows.push(row);\n  }\n}\n\n// Drop rows with missing target\nconst cleaned = rows.filter(r => r[targetVar] !== '' && r[targetVar] !== undefined);\n\n// Numeric conversion\nconst numericFields = ['Age', 'Fare', 'SibSp', 'Parch', 'Pclass', 'PassengerId', targetVar];\ncleaned.forEach(row => {\n  numericFields.forEach(f => {\n    if (row[f] !== undefined && row[f] !== '') {\n      const n = parseFloat(row[f]);\n      if (!isNaN(n)) row[f] = n;\n    }\n  });\n  if (typeof row['Age'] !== 'number' || isNaN(row['Age'])) row['Age'] = 29;\n  row['Sex_enc'] = row['Sex'] === 'male' ? 0 : row['Sex'] === 'female' ? 1 : -1;\n  const embMap = { 'S': 0, 'C': 1, 'Q': 2 };\n  row['Embarked_enc'] = embMap[row['Embarked']] !== undefined ? embMap[row['Embarked']] : 0;\n});\n\nreturn [{ json: {\n  strategy,\n  original_count: rows.length,\n  clean_count: cleaned.length,\n  headers,\n  clean_data: JSON.stringify(cleaned)\n}}];"
      },
      "typeVersion": 2
    }
  ],
  "settings": {
    "executionOrder": "v1"
  },
  "connections": {
    "P2: Fetch CSV": {
      "main": [
        [
          {
            "node": "P2: Clean Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "P2: Clean Data": {
      "main": [
        [
          {
            "node": "P2: Log Data Eng",
            "type": "main",
            "index": 0
          },
          {
            "node": "P3: Reason About Features",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "P1: Parse Strategy": {
      "main": [
        [
          {
            "node": "P1: Log Strategy",
            "type": "main",
            "index": 0
          },
          {
            "node": "P2: Fetch CSV",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "P3: Anthropic Haiku": {
      "ai_languageModel": [
        [
          {
            "node": "P3: Reason About Features",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "P1: Anthropic Sonnet": {
      "ai_languageModel": [
        [
          {
            "node": "P1: Plan ML Strategy",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "P1: Plan ML Strategy": {
      "main": [
        [
          {
            "node": "P1: Parse Strategy",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "P4: Setup Algorithms": {
      "main": [
        [
          {
            "node": "P4: Train All Models",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "P4: Train All Models": {
      "main": [
        [
          {
            "node": "P4: LLM Judge Best Model",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "P1: Receive MLOps Job": {
      "main": [
        [
          {
            "node": "P1: Plan ML Strategy",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "P3: Engineer Features": {
      "main": [
        [
          {
            "node": "P3: Log Feature Eng",
            "type": "main",
            "index": 0
          },
          {
            "node": "P4: Setup Algorithms",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "P3: Parse Feature Plan": {
      "main": [
        [
          {
            "node": "P3: Engineer Features",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "P4: Parse Judge Verdict": {
      "main": [
        [
          {
            "node": "P4: Log Training",
            "type": "main",
            "index": 0
          },
          {
            "node": "P5: Generate Model Card",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "P5: Generate Model Card": {
      "main": [
        [
          {
            "node": "P5: Assemble Deployment Payload",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "P4: LLM Judge Best Model": {
      "main": [
        [
          {
            "node": "P4: Parse Judge Verdict",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "P3: Reason About Features": {
      "main": [
        [
          {
            "node": "P3: Parse Feature Plan",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "P5: Anthropic Sonnet Card": {
      "ai_languageModel": [
        [
          {
            "node": "P5: Generate Model Card",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "P4: Anthropic Sonnet Judge": {
      "ai_languageModel": [
        [
          {
            "node": "P4: LLM Judge Best Model",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "P5: Assemble Deployment Payload": {
      "main": [
        [
          {
            "node": "P5: Send Slack Approval",
            "type": "main",
            "index": 0
          },
          {
            "node": "P5: Log Pipeline Complete",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}