{
  "name": "Data Table Extraction Test w/ Reference Branch powered by easybits",
  "nodes": [
    {
      "parameters": {
        "formTitle": "Data Table Test",
        "formFields": {
          "values": [
            {
              "fieldLabel": "Reference File",
              "fieldType": "file",
              "acceptFileTypes": ".csv,.pdf,.png,.jpeg",
              "requiredField": true
            },
            {
              "fieldLabel": "Document Upload",
              "fieldType": "file",
              "acceptFileTypes": ".pdf,.png,.jpg,.jpeg",
              "requiredField": true
            },
            {
              "fieldLabel": "Extraction Engine",
              "fieldType": "dropdown",
              "fieldOptions": {
                "values": [
                  {
                    "option": "General Extraction Engine"
                  },
                  {
                    "option": "Specialized Extraction Engine"
                  }
                ]
              },
              "requiredField": true
            }
          ]
        },
        "options": {
          "buttonLabel": "Upload"
        }
      },
      "type": "n8n-nodes-base.formTrigger",
      "typeVersion": 2.5,
      "position": [
        16,
        0
      ],
      "id": "945eddc6-2c0c-4d43-bdb2-3995751e003a",
      "name": "On form submission"
    },
    {
      "parameters": {
        "jsCode": "// The reference branch drops binary data, so re-attach the document\n// binary and the engine label from the original form submission.\nconst form = $('On form submission').first();\n\n$input.item.json.startMs = Date.now();\n$input.item.json.engineLabel = form.json['Extraction Engine'];\n\n// Find the uploaded document binary on the form item (the file that is NOT the reference)\nconst bins = form.binary || {};\nconst docKey =\n  bins.Document_Upload ? 'Document_Upload' :\n  bins['Document Upload'] ? 'Document Upload' :\n  Object.keys(bins).find(k => !/reference/i.test(k));\n\nif (!docKey || !bins[docKey]) {\n  throw new Error('Document binary not found on the form item. Binary keys present: ' + Object.keys(bins).join(', '));\n}\n\n// Expose it under \"data\" (the name the easybits Extractor reads),\n// and keep the original key too as a fallback.\n$input.item.binary = {\n  data: bins[docKey],\n  [docKey]: bins[docKey],\n};\n\nreturn $input.item;"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2736,
        -16
      ],
      "id": "cd1b99fe-fc18-41b7-b634-258bb8212722",
      "name": "Code: Start Timer"
    },
    {
      "parameters": {},
      "type": "@easybits/n8n-nodes-extractor.easybitsExtractor",
      "typeVersion": 2,
      "position": [
        3008,
        -16
      ],
      "id": "b20db47d-f90a-49d2-8259-d1c1ad765b82",
      "name": "easybits Extractor",
      "credentials": {
        "easybitsExtractorApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// ===== Code: Validate & Score (v2.1 - reference from Merge) =====\nconst MATCH_KEY = 'si';   // the column that identifies a row. Change if your id column is named differently.\n\n// 1) Extracted side (the document under test)\nconst inp = $('Code: Shape Rows & Time').first().json;\nconst actualRows = inp.rows || [];\nconst engineLabel = inp.engineLabel;\n\n// 2) Reference side (trusted CSV, or human-confirmed PDF, merged into one point)\nconst refRows = ($('Merge: Reference Ready').first().json.referenceRows || [])\n  .map(r => (r && r.json) ? r.json : r);\n\n// Fields to check = every reference column except the match key\nconst FIELDS = refRows.length ? Object.keys(refRows[0]).filter(k => k !== MATCH_KEY) : [];\n\n// helpers\nconst collapse = (s) => String(s ?? '').replace(/\\s+/g, ' ').trim();\nconst isNullTok = (s) => ['', 'null', 'none', 'n/a', 'na'].includes(collapse(s).toLowerCase());\nconst eq = (a, b) => {\n  const ca = collapse(a), cb = collapse(b);\n  if (isNullTok(ca) && isNullTok(cb)) return true;   // NULL == null == empty\n  return ca === cb;                                   // otherwise exact, case-sensitive\n};\n\n// index extracted rows by their match-key value\nconst byId = {};\nfor (const r of actualRows) byId[collapse(r[MATCH_KEY])] = r;\n\nconst mismatches = [];\nconst missingRows = [];\nlet checked = 0, correct = 0;\n\nfor (const exp of refRows) {\n  const id = collapse(exp[MATCH_KEY]);\n  const act = byId[id];\n  if (!act) { missingRows.push(id); continue; }\n  for (const f of FIELDS) {\n    if (collapse(exp[f]) === '') continue;   // blank reference cell = skip (unknown). Put NULL to assert empty.\n    checked++;\n    if (eq(exp[f], act[f])) correct++;\n    else mismatches.push({ id, field: f, expected: collapse(exp[f]), got: collapse(act[f]) || '(empty)' });\n  }\n}\n\nconst refIds = new Set(refRows.map(e => collapse(e[MATCH_KEY])));\nconst extraRows = actualRows.map(r => collapse(r[MATCH_KEY])).filter(id => id && !refIds.has(id));\n\nconst accuracy = checked ? +(100 * correct / checked).toFixed(2) : 0;\nconst pass = mismatches.length === 0 && missingRows.length === 0 && extraRows.length === 0;\n\nconst summary = pass\n  ? `PASS - 100% (${correct}/${checked} cells) using ${engineLabel} in ${inp.elapsedSeconds}s`\n  : `FAIL - ${accuracy}% (${correct}/${checked} cells) using ${engineLabel}, ${mismatches.length} mismatches, ${missingRows.length} missing rows, in ${inp.elapsedSeconds}s`;\n\nconst report = mismatches.map(m => `Row ${m.id} | ${m.field}: expected \"${m.expected}\" got \"${m.got}\"`).join('\\n');\n\n// styled HTML card\nconst statusColor = pass ? '#16a34a' : '#dc2626';\nconst statusBg    = pass ? '#f0fdf4' : '#fef2f2';\nconst statusEmoji = pass ? '\ud83c\udf89' : '\u26a0\ufe0f';\nconst statusWord  = pass ? 'PASS' : 'FAIL';\n\nconst stat = (label, value) => `\n  <div style=\"flex:1;min-width:120px;background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:16px;text-align:center;\">\n    <div style=\"font-size:24px;font-weight:700;color:#111827;\">${value}</div>\n    <div style=\"font-size:12px;color:#6b7280;margin-top:4px;text-transform:uppercase;letter-spacing:.04em;\">${label}</div>\n  </div>`;\n\nconst rowsHtml = mismatches.length\n  ? `<table style=\"width:100%;border-collapse:collapse;margin-top:20px;font-size:13px;\">\n       <tr style=\"text-align:left;color:#6b7280;\">\n         <th style=\"padding:6px 8px;border-bottom:1px solid #e5e7eb;\">Row</th>\n         <th style=\"padding:6px 8px;border-bottom:1px solid #e5e7eb;\">Field</th>\n         <th style=\"padding:6px 8px;border-bottom:1px solid #e5e7eb;\">Expected</th>\n         <th style=\"padding:6px 8px;border-bottom:1px solid #e5e7eb;\">Got</th>\n       </tr>\n       ${mismatches.map(m => `<tr>\n         <td style=\"padding:6px 8px;border-bottom:1px solid #f3f4f6;\">${m.id}</td>\n         <td style=\"padding:6px 8px;border-bottom:1px solid #f3f4f6;\">${m.field}</td>\n         <td style=\"padding:6px 8px;border-bottom:1px solid #f3f4f6;color:#16a34a;\">${m.expected}</td>\n         <td style=\"padding:6px 8px;border-bottom:1px solid #f3f4f6;color:#dc2626;\">${m.got}</td>\n       </tr>`).join('')}\n     </table>`\n  : `<p style=\"margin:20px 0 0;text-align:center;color:#16a34a;font-size:14px;\">Every checked cell matched your reference data.</p>`;\n\nconst html = `\n<style>@keyframes pop{0%{transform:scale(.9);opacity:0}100%{transform:scale(1);opacity:1}}</style>\n<div style=\"font-family:-apple-system,Segoe UI,Roboto,sans-serif;max-width:640px;margin:24px auto;padding:0 16px;animation:pop .35s ease-out;\">\n  <div style=\"background:${statusBg};border:1px solid ${statusColor}33;border-radius:16px;padding:28px;text-align:center;\">\n    <div style=\"font-size:48px;line-height:1;\">${statusEmoji}</div>\n    <div style=\"font-size:30px;font-weight:800;color:${statusColor};margin-top:8px;\">${statusWord} &ndash; ${accuracy}%</div>\n    <div style=\"font-size:14px;color:#6b7280;margin-top:6px;\">${engineLabel}</div>\n  </div>\n  <div style=\"display:flex;gap:12px;flex-wrap:wrap;margin-top:16px;\">\n    ${stat('Cells correct', `${correct}/${checked}`)}\n    ${stat('Rows read', `${actualRows.length}/${refRows.length}`)}\n    ${stat('Time', `${inp.elapsedSeconds}s`)}\n  </div>\n  ${rowsHtml}\n</div>`;\n\nreturn [{ json: {\n  pass,\n  accuracy_pct: accuracy,\n  cells_checked: checked,\n  cells_correct: correct,\n  rows_expected: refRows.length,\n  rows_extracted: inp.rowCount,\n  missing_rows: missingRows,\n  extra_row_ids: extraRows,\n  mismatch_count: mismatches.length,\n  mismatches,\n  engine_label: engineLabel,\n  extraction_ms: inp.elapsedMs,\n  extraction_seconds: inp.elapsedSeconds,\n  summary,\n  report,\n  html,\n} }];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3552,
        -16
      ],
      "id": "5a811b22-acaa-44e7-88bc-9f4fba895c93",
      "name": "Code: Validate & Score"
    },
    {
      "parameters": {
        "jsCode": "const startMs = $('Code: Start Timer').first().json.startMs;\nconst engineLabel = $('Code: Start Timer').first().json.engineLabel;\nconst elapsedMs = Date.now() - Number(startMs);\n\nconst out = $input.first().json || {};\nconst data = out.data ?? out;               // Extractor wraps output in `data`\n\nlet rows = null;\nif (Array.isArray(data)) rows = data;\nelse if (Array.isArray(data.records)) rows = data.records;\nelse if (Array.isArray(data.rows)) rows = data.rows;\nelse { for (const k of Object.keys(data)) { if (Array.isArray(data[k])) { rows = data[k]; break; } } }\nif (!rows) rows = [];\n\nconst norm = (v) => (v === null || v === undefined) ? '' : String(v).trim();\nconst shaped = rows.map((r) => ({\n  si: norm(r.si),\n  new_tin: norm(r.new_tin),\n  asses_name: norm(r.asses_name),\n  father_name: norm(r.father_name),\n  contact_email_address: norm(r.contact_email_address),\n  current_address: norm(r.current_address),\n}));\n\nreturn [{ json: {\n  engineLabel,\n  elapsedMs,\n  elapsedSeconds: +(elapsedMs / 1000).toFixed(2),\n  rowCount: shaped.length,\n  rows: shaped,\n} }];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3280,
        -16
      ],
      "id": "13f5d9b4-fcdc-40f2-b8ea-a4a4a3eb5345",
      "name": "Code: Shape Rows & Time"
    },
    {
      "parameters": {
        "operation": "append",
        "documentId": {
          "__rl": true,
          "value": "YOUR_SPREADSHEET_ID",
          "mode": "list",
          "cachedResultName": "YOUR_SPREADSHEET_NAME",
          "cachedResultUrl": "YOUR_SPREADSHEET_URL"
        },
        "sheetName": {
          "__rl": true,
          "value": "gid=0",
          "mode": "list",
          "cachedResultName": "YOUR_SHEET_TAB",
          "cachedResultUrl": "YOUR_SHEET_URL"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "run_at": "={{ $now.toISO() }}",
            "engine_label": "={{ $json.engine_label }}",
            "pass": "={{ $json.pass }}",
            "accuracy_pct": "={{ $json.accuracy_pct }}",
            "extraction_seconds": "={{ $json.extraction_seconds }}",
            "mismatch_count": "={{ $json.mismatch_count }}"
          },
          "matchingColumns": [],
          "schema": [
            {
              "id": "run_at",
              "displayName": "run_at",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "engine_label",
              "displayName": "engine_label",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true,
              "removed": false
            },
            {
              "id": "pass",
              "displayName": "pass",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "accuracy_pct",
              "displayName": "accuracy_pct",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "extraction_seconds",
              "displayName": "extraction_seconds",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "mismatch_count",
              "displayName": "mismatch_count",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            }
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {}
      },
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.7,
      "position": [
        3824,
        -16
      ],
      "id": "3c788f59-7400-4a8d-ae4a-3766b855d0ba",
      "name": "Google Sheets: Append Test Log",
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "operation": "completion",
        "respondWith": "showText",
        "responseText": "={{ $('Code: Validate & Score').first().json.html }}"
      },
      "type": "n8n-nodes-base.form",
      "typeVersion": 2.5,
      "position": [
        4096,
        -16
      ],
      "id": "9fb8f026-c7d3-4b0c-9c50-f394e49ea024",
      "name": "Form"
    },
    {
      "parameters": {
        "content": "## \ud83e\uddea Data Table Extraction Tester\n(powered by easybits)\n\nThis workflow is built to benchmark and fine-tune data table extraction pipelines made with the easybits Extractor. Upload a document, pick which engine you used, and get an instant pass/fail report that checks every extracted cell against a reference you provide, plus how long the extraction took, so you can measure accuracy, compare the General and Specialized engines, and tighten your field descriptions before trusting a pipeline in production.\n\n## \u2699\ufe0f How it works\n\n1. You upload two files on the form: the document to test, and a reference (either a CSV you trust, or a PDF/image the workflow extracts and asks you to confirm).\n2. A CSV reference is trusted as-is. A PDF/image reference is extracted, shown back to you, and only used once you confirm it is correct.\n3. The document under test is extracted, timed, and flattened into rows.\n4. Every cell is compared against the reference, scored, and any mismatches are listed.\n5. The run is logged to a Google Sheet and shown as a styled pass/fail card, so each mismatch points you straight at the field description or engine choice to fine-tune next.\n\n## \ud83d\udd0c Set up the two Extractor pipelines\n\nBoth Extractor nodes point at a pipeline you build for free at https://extractor.easybits.tech. Create one pipeline, set the response structure, and reuse it in both nodes (or make two if you want to test General vs Specialized separately).\n\nIn the pipeline builder, create a single field named \"records\", mark it as an array, and set its type to object so it shows as object [ ]. Then add each column as a nested field underneath it, all as string except si which can be number: si, new_tin, asses_name, father_name, contact_email_address, current_address.\n\nThe pipeline Description field describes the document itself. The detailed extraction rules (columns left to right, treat NULL as literal text, keep two-line addresses in one cell) go on the \"records\" field description. The full walkthrough is in the setup guide in this repo.\n\n## \ud83d\ude80 Connect it in n8n\n\n1. On n8n Cloud, search \"easybits Extractor\" in the node panel (verified, no install). Self-hosted, install @easybits/n8n-nodes-extractor from Settings, Community Nodes.\n2. Create a free account at https://go.easybits.tech/cn, then add your API key as an easybits credential in n8n.\n3. Point both Extractor nodes at your pipeline. Use your most accurate (Specialized) pipeline on the reference node, since a human signs off on that output.\n\n## \ud83d\udccb Before you run\n\n1. Open \"Code: Validate & Score\" and set MATCH_KEY to your row-id column (si here). Edit \"Code: Normalize Reference (CSV)\" COLUMN_MAP so your CSV headers map to your pipeline field names.\n2. Reference CSV rule: leave a cell blank to skip checking it, type NULL to assert the cell is empty, and format any id column as Text so leading zeros survive.\n3. Optional: add Google Sheets credentials and a log sheet with columns run_at, engine_label, pass, accuracy_pct, extraction_seconds, mismatch_count.",
        "height": 1184,
        "width": 752
      },
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -832,
        -544
      ],
      "typeVersion": 1,
      "id": "74259dd2-a82b-4697-baea-95b923c3f806",
      "name": "Sticky Note"
    },
    {
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "const bin = $input.item.binary.Reference_File;\n   const ext = (bin.fileExtension || '').toLowerCase();\n   $input.item.json.refType = (ext === 'csv') ? 'csv' : 'doc';\n   return $input.item;"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        288,
        0
      ],
      "id": "9ef4204c-c6d1-44aa-a924-8431432c034c",
      "name": "Code: Detect Reference Type"
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 3
                },
                "conditions": [
                  {
                    "leftValue": "={{ $json.refType }}",
                    "rightValue": "csv",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    },
                    "id": "6c6549b4-1fc9-4872-9a88-f6b2b2757978"
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "csv"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 3
                },
                "conditions": [
                  {
                    "id": "0485289c-7f89-441e-8869-2b0152d925f3",
                    "leftValue": "={{ $json.refType }}",
                    "rightValue": "doc",
                    "operator": {
                      "type": "string",
                      "operation": "equals",
                      "name": "filter.operator.equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "doc"
            }
          ]
        },
        "options": {}
      },
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3.4,
      "position": [
        560,
        0
      ],
      "id": "606eb866-2f77-4568-a576-3107e418a12c",
      "name": "Switch: Reference Type"
    },
    {
      "parameters": {
        "binaryPropertyName": "Reference_File",
        "options": {}
      },
      "type": "n8n-nodes-base.extractFromFile",
      "typeVersion": 1.1,
      "position": [
        832,
        -208
      ],
      "id": "3e0da5be-11e2-4118-bf47-9e0ce6123bb7",
      "name": "Extract From File: Reference CSV"
    },
    {
      "parameters": {
        "aggregate": "aggregateAllItemData",
        "options": {}
      },
      "type": "n8n-nodes-base.aggregate",
      "typeVersion": 1,
      "position": [
        1104,
        -208
      ],
      "id": "93e20357-d2fe-48aa-aee5-3bcc55ae85d9",
      "name": "Aggregate Reference"
    },
    {
      "parameters": {
        "jsCode": "// CSV reference: already human-verified, trusted as-is\n   const rows = ($('Aggregate Reference').first().json.data || [])\n     .map(r => (r && r.json) ? r.json : r);\n   return [{ json: { referenceRows: rows, source: 'csv' } }];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1376,
        -208
      ],
      "id": "d3ba881d-ced7-4ea5-99d8-476f7bb944c7",
      "name": "Code: Normalize Reference (CSV)"
    },
    {
      "parameters": {},
      "type": "@easybits/n8n-nodes-extractor.easybitsExtractor",
      "typeVersion": 2,
      "position": [
        1104,
        224
      ],
      "id": "bbe76bae-8649-45b2-af0a-5e664a433648",
      "name": "easybits Extractor: Reference",
      "credentials": {
        "easybitsExtractorApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "const out = $input.first().json || {};\nconst data = out.data ?? out;\nlet rows = Array.isArray(data) ? data\n  : Array.isArray(data.records) ? data.records\n  : (Object.values(data).find(Array.isArray) || []);\n\nconst norm = (v) => (v === null || v === undefined) ? '' : String(v).trim();\nrows = rows.map(r => { const o = {}; for (const k of Object.keys(r)) o[k] = norm(r[k]); return o; });\n\nconst cols = rows.length ? Object.keys(rows[0]) : [];\nconst th = cols.map(c => `<th style=\"padding:10px 12px;background:#f9fafb;border-bottom:2px solid #eef2f7;text-align:left;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:#6b7280;white-space:nowrap;\">${c}</th>`).join('');\nconst trs = rows.map((r, i) => `<tr style=\"background:${i % 2 ? '#fcfcfd' : '#fff'};\">${cols.map(c => `<td style=\"padding:9px 12px;border-bottom:1px solid #f3f4f6;font-size:12.5px;color:#374151;\">${r[c] || '<span style=\"color:#dc2626;font-style:italic;\">empty</span>'}</td>`).join('')}</tr>`).join('');\n\nconst previewHtml = `\n<div style=\"font-family:-apple-system,Segoe UI,Roboto,sans-serif;max-width:820px;margin:12px auto;\">\n  <div style=\"text-align:center;margin-bottom:20px;\">\n    <div style=\"font-size:34px;line-height:1;\">\ud83d\udd0d</div>\n    <h2 style=\"font-size:22px;font-weight:800;color:#111827;margin:10px 0 4px;\">Check your reference data</h2>\n    <p style=\"font-size:14px;color:#6b7280;margin:0;\">This is what was read from your reference file. It becomes the ground truth for the test, so every cell needs to be correct.</p>\n  </div>\n  <div style=\"background:#fffbeb;border:1px solid #fde68a;border-radius:10px;padding:12px 16px;margin-bottom:18px;font-size:13px;color:#92400e;\">\n    <b>Before you continue:</b> confirm the values below match the document exactly. IDs should keep any leading zeros, and empty cells should read <i>empty</i>. If anything is off, choose \"No\" and re-upload a corrected CSV.\n  </div>\n  <div style=\"border:1px solid #e5e7eb;border-radius:12px;overflow:hidden;\">\n    <div style=\"overflow-x:auto;\">\n      <table style=\"border-collapse:collapse;width:100%;\">\n        <thead><tr>${th}</tr></thead>\n        <tbody>${trs}</tbody>\n      </table>\n    </div>\n  </div>\n  <p style=\"font-size:12px;color:#9ca3af;text-align:center;margin:14px 0 0;\">${rows.length} row${rows.length === 1 ? '' : 's'} read from your file</p>\n</div>`;\n\nreturn [{ json: { referenceRows: rows, previewHtml } }];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1376,
        224
      ],
      "id": "70f9d4d5-58c4-4c38-81ef-5eeb71a91a3e",
      "name": "Code: Shape Reference + Preview"
    },
    {
      "parameters": {
        "formFields": {
          "values": [
            {
              "fieldLabel": "Reference correct?",
              "fieldType": "dropdown",
              "fieldOptions": {
                "values": [
                  {
                    "option": "Yes, run the test"
                  },
                  {
                    "option": "No, cancel"
                  }
                ]
              },
              "requiredField": true
            }
          ]
        },
        "options": {
          "formDescription": "={{ $json.previewHtml }}",
          "buttonLabel": "Continue"
        }
      },
      "type": "n8n-nodes-base.form",
      "typeVersion": 2.5,
      "position": [
        1648,
        224
      ],
      "id": "b9bc3b3a-b1cf-4676-97cb-f30458abd5ac",
      "name": "Form: Confirm Reference"
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 3
          },
          "conditions": [
            {
              "id": "07312ed9-5388-49c3-8493-25419bc8c407",
              "leftValue": "={{ $json['Reference correct?'] }}",
              "rightValue": "Yes, run the test",
              "operator": {
                "type": "string",
                "operation": "equals",
                "name": "filter.operator.equals"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.3,
      "position": [
        1920,
        224
      ],
      "id": "b96fa0fb-b7e5-4c41-b155-889ac4b4623e",
      "name": "If: Confirmed?"
    },
    {
      "parameters": {
        "jsCode": "// Human confirmed the extracted reference, so trust it\n   const rows = $('Code: Shape Reference + Preview').first().json.referenceRows || [];\n   return [{ json: { referenceRows: rows, source: 'pdf-confirmed' } }];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2192,
        -16
      ],
      "id": "cb6bb45b-bdd6-4e5b-b16a-d33cc06ed989",
      "name": "Code: Normalize Reference (PDF)"
    },
    {
      "parameters": {
        "operation": "completion",
        "completionTitle": "Test cancelled",
        "completionMessage": "Fix the reference values and re-upload it as a CSV.",
        "options": {}
      },
      "type": "n8n-nodes-base.form",
      "typeVersion": 2.5,
      "position": [
        2192,
        416
      ],
      "id": "09cacb04-9c96-4828-8357-abb5e8607ba0",
      "name": "Form Ending: Cancelled"
    },
    {
      "parameters": {},
      "type": "n8n-nodes-base.merge",
      "typeVersion": 3.2,
      "position": [
        2464,
        -16
      ],
      "id": "f0365691-18f2-47f0-bfd5-bdcc2d9fb2a1",
      "name": "Merge: Reference Ready"
    },
    {
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "$input.item.binary = { data: $input.item.binary.Reference_File };\n   return $input.item;"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        832,
        224
      ],
      "id": "77ad35a1-48d1-4e52-86c5-b7e7078bc209",
      "name": "Code: Rename Reference Binary"
    },
    {
      "parameters": {
        "content": "## \ud83d\udce5 Upload document + reference\n\nCollects the document to test, a reference file (CSV or PDF/image), and an engine label. The two file fields produce binaries named Document_Upload and Reference_File.",
        "height": 416,
        "width": 256,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -64,
        -256
      ],
      "typeVersion": 1,
      "id": "586616bd-950f-46d8-923e-3f6801008520",
      "name": "Sticky Note1"
    },
    {
      "parameters": {
        "content": "## \ud83c\udff7\ufe0f Tag the reference type\n\nReads the reference file extension and sets refType to csv or doc. The next node routes on that tag.",
        "height": 416,
        "width": 256,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        208,
        -256
      ],
      "typeVersion": 1,
      "id": "e5d6a92f-1227-45a9-9e83-1c52975cfa96",
      "name": "Sticky Note2"
    },
    {
      "parameters": {
        "content": "## \ud83d\udd00 Split CSV vs document\n\nCSV references go straight to parsing (trusted), document references go to extraction plus a human check. A PDF is not ground truth until someone confirms it.",
        "height": 416,
        "width": 256,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        480,
        -256
      ],
      "typeVersion": 1,
      "id": "e9bd3285-d3c7-477f-ad68-a0446fe43cb6",
      "name": "Sticky Note3"
    },
    {
      "parameters": {
        "content": "## \ud83d\udcc4 Parse the reference CSV\n\nTurns the CSV into one item per row, headers as field names. Handles quoted fields, so comma-filled addresses stay intact.",
        "height": 416,
        "width": 256,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        752,
        -464
      ],
      "typeVersion": 1,
      "id": "546d79c2-8ce1-4d0c-a061-79d2ce5cdf11",
      "name": "Sticky Note4"
    },
    {
      "parameters": {
        "content": "## \ud83d\udd01 Rename reference binary\n\nCopies the reference file into a binary field named data, which the Extractor reads. Without it the reference Extractor cannot find the file.",
        "height": 416,
        "width": 256,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        752,
        -32
      ],
      "typeVersion": 1,
      "id": "b32748af-9a29-4859-a464-a6790b7296b4",
      "name": "Sticky Note5"
    },
    {
      "parameters": {
        "content": "## \ud83d\udce6 Aggregate reference rows\n\nRolls all CSV rows into one item under a data field. Lets the validator take the whole reference as one list.",
        "height": 416,
        "width": 256,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1024,
        -464
      ],
      "typeVersion": 1,
      "id": "a56788f9-774a-42c2-b373-a609f91fe905",
      "name": "Sticky Note6"
    },
    {
      "parameters": {
        "content": "## \ud83e\uddf9 Normalize reference (CSV)\n\nRenames your CSV headers to your pipeline field names via COLUMN_MAP, then outputs them as referenceRows. Edit COLUMN_MAP so the left side matches your CSV headers exactly.",
        "height": 416,
        "width": 256,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1296,
        -464
      ],
      "typeVersion": 1,
      "id": "1ee9957b-0a89-413b-9dfd-46d894de2b56",
      "name": "Sticky Note7"
    },
    {
      "parameters": {
        "content": "## \ud83d\udd0d Extract the reference (document)\n\nRuns a PDF or image reference through the Extractor to read its table. Use your most accurate pipeline, since this output becomes the ground truth.",
        "height": 416,
        "width": 256,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1024,
        -32
      ],
      "typeVersion": 1,
      "id": "8e99da4f-1cdf-4c61-84fb-d54ae77a44e4",
      "name": "Sticky Note8"
    },
    {
      "parameters": {
        "content": "## \ud83d\uddbc\ufe0f Shape reference + build preview\n\nCleans the extracted rows and builds an HTML table of every value. This preview is what the confirm page shows.",
        "height": 416,
        "width": 256,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1296,
        -32
      ],
      "typeVersion": 1,
      "id": "a7b8fe53-3a13-4a2a-b650-829de68d7087",
      "name": "Sticky Note9"
    },
    {
      "parameters": {
        "content": "## \u2705 Confirm the reference\n\nShows the extracted reference and asks if it is correct. This human sign-off is what turns a PDF extraction into trusted ground truth.",
        "height": 416,
        "width": 256,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1568,
        -32
      ],
      "typeVersion": 1,
      "id": "81147bba-b5e0-4273-af1f-1666354188da",
      "name": "Sticky Note10"
    },
    {
      "parameters": {
        "content": "## \u2194\ufe0f Route on the answer\n\nSends Yes on to be used, No to a cancel page. Choosing No stops the run cleanly.",
        "height": 416,
        "width": 256,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1840,
        -32
      ],
      "typeVersion": 1,
      "id": "21bf8026-db29-42ed-acc7-d1d68d40a4ee",
      "name": "Sticky Note11"
    },
    {
      "parameters": {
        "content": "## \ud83e\uddf9 Normalize reference (PDF)\n\nPasses the confirmed extraction forward as referenceRows, same field as the CSV branch. From here both reference types look identical.",
        "height": 416,
        "width": 256,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2112,
        -256
      ],
      "typeVersion": 1,
      "id": "a4733ee7-20bb-4252-9038-93f854dca6fc",
      "name": "Sticky Note12"
    },
    {
      "parameters": {
        "content": "## \ud83d\udeab Cancelled ending\n\nEnds the run when you flag the reference as wrong. Fix the values, export as CSV, start a new test.",
        "height": 416,
        "width": 256,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2112,
        176
      ],
      "typeVersion": 1,
      "id": "d59104aa-318c-44b7-b9f7-6233eaab34ba",
      "name": "Sticky Note13"
    },
    {
      "parameters": {
        "content": "## \ud83d\udd17 Reference ready\n\nMerges the two reference paths into one line. Only one branch runs, so it just carries referenceRows forward.",
        "height": 416,
        "width": 256,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2384,
        -256
      ],
      "typeVersion": 1,
      "id": "cf3cadd6-0216-426c-be04-1908e9474395",
      "name": "Sticky Note14"
    },
    {
      "parameters": {
        "content": "## \u23f1\ufe0f Start timer + re-attach document\n\nRecords the start time, reads the engine label, and re-attaches the document binary as data. The re-attach matters because the reference branch drops binary data.",
        "height": 416,
        "width": 256,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2656,
        -256
      ],
      "typeVersion": 1,
      "id": "ec75ba41-7901-45d1-bb60-308e3e983207",
      "name": "Sticky Note15"
    },
    {
      "parameters": {
        "content": "## \ud83e\udd16 Extract the document under test\n\nRuns the document being tested through the Extractor pipeline. This is the extraction whose accuracy gets measured.",
        "height": 416,
        "width": 256,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2928,
        -256
      ],
      "typeVersion": 1,
      "id": "131f5554-d1b8-4d6f-82ed-054822e4e579",
      "name": "Sticky Note16"
    },
    {
      "parameters": {
        "content": "## \ud83e\uddf1 Shape rows + measure time\n\nFlattens the Extractor output into clean rows and times the extraction. Handles the data envelope and varying array shapes.",
        "height": 416,
        "width": 256,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        3200,
        -256
      ],
      "typeVersion": 1,
      "id": "d567d624-5a94-4eee-ad83-1d7e7fdbacc1",
      "name": "Sticky Note17"
    },
    {
      "parameters": {
        "content": "## \ud83c\udfaf Validate & score\n\nCompares every extracted cell against the reference, matching rows by MATCH_KEY. Blank reference cells are skipped, NULL asserts an empty cell.",
        "height": 416,
        "width": 256,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        3472,
        -256
      ],
      "typeVersion": 1,
      "id": "d1d18b71-7ae8-4fbf-8f6c-8d4055d25aff",
      "name": "Sticky Note18"
    },
    {
      "parameters": {
        "content": "## \ud83d\udcca Log the result\n\nAppends one row per run to your Google Sheet: time, engine, pass, accuracy, and mismatch count. Optional, handy for comparing engines over time.",
        "height": 416,
        "width": 256,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        3744,
        -256
      ],
      "typeVersion": 1,
      "id": "cb80a7d2-72de-4cf3-b42d-a0c7cf3f6f04",
      "name": "Sticky Note19"
    },
    {
      "parameters": {
        "content": "## \ud83c\udf89 Result page\n\nShows the pass/fail card with cells correct, rows read, time, and any mismatches. This is the completion screen at the end of a run.",
        "height": 416,
        "width": 256,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        4016,
        -256
      ],
      "typeVersion": 1,
      "id": "b55f8fad-09ff-41d4-8ac5-699f24bad7ad",
      "name": "Sticky Note20"
    }
  ],
  "connections": {
    "On form submission": {
      "main": [
        [
          {
            "node": "Code: Detect Reference Type",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Code: Start Timer": {
      "main": [
        [
          {
            "node": "easybits Extractor",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "easybits Extractor": {
      "main": [
        [
          {
            "node": "Code: Shape Rows & Time",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Code: Shape Rows & Time": {
      "main": [
        [
          {
            "node": "Code: Validate & Score",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Code: Validate & Score": {
      "main": [
        [
          {
            "node": "Google Sheets: Append Test Log",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Google Sheets: Append Test Log": {
      "main": [
        [
          {
            "node": "Form",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Code: Detect Reference Type": {
      "main": [
        [
          {
            "node": "Switch: Reference Type",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Switch: Reference Type": {
      "main": [
        [
          {
            "node": "Extract From File: Reference CSV",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Code: Rename Reference Binary",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract From File: Reference CSV": {
      "main": [
        [
          {
            "node": "Aggregate Reference",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Aggregate Reference": {
      "main": [
        [
          {
            "node": "Code: Normalize Reference (CSV)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "easybits Extractor: Reference": {
      "main": [
        [
          {
            "node": "Code: Shape Reference + Preview",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Code: Shape Reference + Preview": {
      "main": [
        [
          {
            "node": "Form: Confirm Reference",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Form: Confirm Reference": {
      "main": [
        [
          {
            "node": "If: Confirmed?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "If: Confirmed?": {
      "main": [
        [
          {
            "node": "Code: Normalize Reference (PDF)",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Form Ending: Cancelled",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Code: Normalize Reference (CSV)": {
      "main": [
        [
          {
            "node": "Merge: Reference Ready",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Code: Normalize Reference (PDF)": {
      "main": [
        [
          {
            "node": "Merge: Reference Ready",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Merge: Reference Ready": {
      "main": [
        [
          {
            "node": "Code: Start Timer",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Code: Rename Reference Binary": {
      "main": [
        [
          {
            "node": "easybits Extractor: Reference",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1",
    "availableInMCP": false
  }
}