{
  "name": "Check AI extracted fields against the source text with HTTP Request and Code",
  "nodes": [
    {
      "parameters": {
        "content": "## Check AI extracted fields against the source text\n\nCatches an invented value before it reaches your database, by requiring every field the model returns to actually appear in the document it read.\n\n### Who's it for\nAnyone extracting structured data from documents with an LLM: invoices, contracts, forms, CVs. Any workflow where a plausible wrong value is worse than no value.\n\n### How it works\n- **Extract the fields** asks the model for structured JSON from the document text.\n- **Check against the source** compares every returned field back to the source string. A field the document does not contain is not an extraction, it is a guess.\n- **Every field grounded?** routes clean results straight through.\n- On a first failure, **Retry with the failure** sends the specific ungrounded fields back to the model as feedback and tries once more.\n- A second failure goes to **Refuse and escalate**, which returns nothing rather than writing an invented value downstream.\n\n### Setup\n1. Replace **Load the document** with your real source: an OCR step, a PDF extract node, a database row. It only has to emit the document text.\n2. Open **Extract the fields**, attach your credential under Authentication, Generic, Header Auth, and edit the field list to the fields you actually need.\n3. Point **Refuse and escalate** at wherever a human should pick it up.\n\n### Requirements\nAn API credential for any chat completion endpoint. No community nodes.\n\n### How to customize\n**Check against the source** does exact substring matching after normalising whitespace and case. For dates and numbers that get reformatted, relax it per field rather than globally.\n\nFull walkthrough: https://www.theagentecosystem.com/blog/n8n-ai-agent-hallucinations",
        "height": 1058,
        "width": 760,
        "color": 1
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -40,
        -628
      ],
      "name": "Note: Overview"
    },
    {
      "parameters": {
        "content": "## 1. Load the document\nSwap this for OCR, a PDF extract or a database row. It only has to emit text.",
        "height": 260,
        "width": 480,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -40,
        670
      ],
      "name": "Note: Load the document"
    },
    {
      "parameters": {
        "content": "## 2. Extract, then verify\nAsk the model for JSON, then check every field back against the source string.",
        "height": 260,
        "width": 480,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        440,
        670
      ],
      "name": "Note: Extract, then verify"
    },
    {
      "parameters": {
        "content": "## 3. Grounded, or one retry\nClean results pass. A first failure goes back to the model with the specific bad fields.",
        "height": 620,
        "width": 480,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        920,
        550
      ],
      "name": "Note: Grounded, or one retry"
    },
    {
      "parameters": {
        "content": "## 4. Refuse rather than invent\nA second failure writes nothing and escalates to a person.",
        "height": 380,
        "width": 480,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1400,
        670
      ],
      "name": "Note: Refuse rather than invent"
    },
    {
      "parameters": {},
      "id": "f1a2b3c4-0000-4000-8000-000000000003",
      "name": "Run the extraction",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        0,
        820
      ]
    },
    {
      "parameters": {
        "jsCode": "// Stand-in for your real source. Swap for a PDF text extraction, an email\n// body, or a scraped page. The only requirement is that the RAW text survives\n// into sourceText, because that is what every extracted value is checked\n// against. Reconstructing it later from a summary defeats the whole pattern.\nconst sourceText = `INVOICE\\n\\nNorthwind Traders Ltd\\nInvoice number: INV-2026-0817\\nIssue date: 14 August 2026\\nDue date: 13 September 2026\\n\\nDescription: Quarterly platform subscription\\nSubtotal: 1,240.00 GBP\\nVAT (20%): 248.00 GBP\\nTotal due: 1,488.00 GBP\\n\\nRemit to: Northwind Traders Ltd, Account 40-11-92`;\n\nreturn [{ json: { sourceText, attempt: 1, feedback: '' } }];"
      },
      "id": "f1a2b3c4-0000-4000-8000-000000000004",
      "name": "Load the document",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        240,
        820
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.openai.com/v1/chat/completions",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ model: 'gpt-4o-mini', messages: [{ role: 'user', content: `Extract these fields from the document as JSON with exactly these keys: invoiceNumber, issueDate, totalDue, supplier.\\nCopy values EXACTLY as they appear. Do not reformat, round, or infer anything.\\nReturn only the JSON object.\\n\\n${$json.feedback}\\n\\nDOCUMENT:\\n${$json.sourceText}` }] }) }}",
        "options": {
          "response": {
            "response": {
              "fullResponse": true,
              "neverError": true
            }
          }
        }
      },
      "id": "f1a2b3c4-0000-4000-8000-000000000005",
      "name": "Extract the fields",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        480,
        820
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 2000,
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "jsCode": "// Extractive validation: every field marked `grounded` must actually appear\n// in the source text. A hallucination IS a value that is not in the source, so\n// checking presence catches it without a second model or any judgement call.\n//\n// Mark a field grounded:false when the value is legitimately derived rather\n// than copied (a computed status, a classification). Those need a different\n// check; this one would reject them for the wrong reason.\nconst FIELDS = [\n  { key: 'invoiceNumber', grounded: true },\n  { key: 'issueDate', grounded: true },\n  { key: 'totalDue', grounded: true },\n  { key: 'supplier', grounded: true },\n];\n\nconst prev = $('Load the document').first().json;\nconst res = $input.first().json;\nconst raw = res.body?.choices?.[0]?.message?.content ?? '';\n\nlet extracted;\ntry {\n  extracted = JSON.parse(raw.replace(/^```(?:json)?|```$/g, '').trim());\n} catch {\n  return [{ json: { ...prev, valid: false, ungrounded: ['(model did not return JSON)'], extracted: null } }];\n}\n\n// Compare on a normalised form so formatting differences are not mistaken for\n// invention. Keep this deliberately mild: strip case, spaces and thousands\n// separators, nothing more. Normalise too aggressively and a genuinely\n// hallucinated value starts matching something innocent in the document.\nconst norm = (s) => String(s).toLowerCase().replace(/[\\s,]/g, '');\nconst haystack = norm(prev.sourceText);\n\nconst ungrounded = [];\nfor (const f of FIELDS) {\n  const value = extracted?.[f.key];\n  if (value == null || value === '') { ungrounded.push(`${f.key} (missing)`); continue; }\n  if (f.grounded && !haystack.includes(norm(value))) ungrounded.push(`${f.key} = \"${value}\"`);\n}\n\nreturn [{ json: { ...prev, extracted, valid: ungrounded.length === 0, ungrounded } }];"
      },
      "id": "f1a2b3c4-0000-4000-8000-000000000006",
      "name": "Check against the source",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        720,
        820
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "f1a2b3c4-0000-4000-8000-000000000011",
              "leftValue": "={{ $json.valid }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "f1a2b3c4-0000-4000-8000-000000000007",
      "name": "Every field grounded?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        960,
        820
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "f1a2b3c4-0000-4000-8000-000000000012",
              "leftValue": "={{ $json.attempt }}",
              "rightValue": 1,
              "operator": {
                "type": "number",
                "operation": "lte"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "f1a2b3c4-0000-4000-8000-000000000008",
      "name": "First failure?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1200,
        940
      ]
    },
    {
      "parameters": {
        "jsCode": "// Name the exact values that were not in the document and ask once more.\n// Models frequently correct a hallucination when told precisely which value\n// failed; they rarely correct it on a third attempt having missed the second.\nconst j = $json;\nreturn [{\n  json: {\n    sourceText: j.sourceText,\n    attempt: (j.attempt || 1) + 1,\n    feedback: `Your previous answer contained values that do not appear in the document: ${j.ungrounded.join('; ')}. Copy values exactly as written in the document. If a field is genuinely absent, return null for it rather than guessing.`,\n  },\n}];"
      },
      "id": "f1a2b3c4-0000-4000-8000-000000000009",
      "name": "Retry with the failure",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        960,
        1060
      ]
    },
    {
      "parameters": {
        "jsCode": "// Grounded. Safe to write downstream.\nreturn [{ json: { accepted: true, attempt: $json.attempt, fields: $json.extracted } }];"
      },
      "id": "f1a2b3c4-0000-4000-8000-00000000000a",
      "name": "Accept the extraction",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1200,
        700
      ]
    },
    {
      "parameters": {
        "jsCode": "// Failed twice. REFUSE rather than writing a best guess.\n//\n// This is the branch that gives the whole pattern its value. A pipeline that\n// writes its best guess when validation fails does not have validation, it has\n// a log line. Route this to a human queue, and count these: a rising refusal\n// rate usually means the documents changed, not that the model got worse.\nreturn [{\n  json: {\n    accepted: false,\n    reason: 'values not found in source after retry',\n    ungrounded: $json.ungrounded,\n    extracted: $json.extracted,\n  },\n}];"
      },
      "id": "f1a2b3c4-0000-4000-8000-00000000000b",
      "name": "Refuse and escalate",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1440,
        940
      ]
    },
    {
      "parameters": {},
      "id": "f1a2b3c4-0000-4000-8000-00000000000c",
      "name": "Done",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        1680,
        820
      ]
    }
  ],
  "connections": {
    "Run the extraction": {
      "main": [
        [
          {
            "node": "Load the document",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Load the document": {
      "main": [
        [
          {
            "node": "Extract the fields",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract the fields": {
      "main": [
        [
          {
            "node": "Check against the source",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check against the source": {
      "main": [
        [
          {
            "node": "Every field grounded?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Every field grounded?": {
      "main": [
        [
          {
            "node": "Accept the extraction",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "First failure?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "First failure?": {
      "main": [
        [
          {
            "node": "Retry with the failure",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Refuse and escalate",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Retry with the failure": {
      "main": [
        [
          {
            "node": "Extract the fields",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Accept the extraction": {
      "main": [
        [
          {
            "node": "Done",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Refuse and escalate": {
      "main": [
        [
          {
            "node": "Done",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "active": false,
  "tags": []
}
