{
  "name": "Write a summary of a long document",
  "nodes": [
    {
      "parameters": {
        "content": "## Write a summary of a long document\nTurn a long report, policy or paper into a short, faithful summary for a named reader, with every number and name kept exactly as the document has it.\n\n**How to use**\n1. Open **Chat model** and pick your own credential. To use another AI service, delete that node, add the service's chat model node, and connect it to the Model plug under every step and AI check.\n2. Open **Inputs** and paste your material into each field. Examples are filled in so you can try it first.\n3. Click **Execute workflow**.\n4. Open **Result** to read the answer, anything flagged, and the checks left for you.\n\n**Checks**\n- Check 1 (a plain rule, run automatically with no AI): Every number in the answer also appears in the document. If it fails: try once more, then flag.\n- Check 2 (a plain rule, run automatically with no AI): The answer is no longer than 300 words. If it fails: try once more, then flag.\n- Check 3 (a plain rule, run automatically with no AI): Every number in the answer also appears in the document. If it fails: try once more, then flag.\n- Check 4 (a second AI prompt answers pass or fail): Check every name of a person, place, organization, program and document in the summary against the source document below. Is each one spelled and used exactly as the source has it, and does each one appear in the source? If it fails: try once more, then flag.\n- Check 5 (you check it by hand): Would the document's author agree that the summary gives a fair picture, with nothing important left out and nothing made stronger or weaker? If it fails: flag it and carry on.\n\nFrom Logic Lab (logiclabhq.com). Free to use and adapt, with credit.",
        "height": 580,
        "width": 760,
        "color": 5
      },
      "id": "21b33a2d-bd15-4ca2-a0be-41a7949aeab4",
      "name": "About this workflow",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -40,
        -540
      ]
    },
    {
      "parameters": {},
      "id": "ccb95d05-807f-43d6-a842-7dd7bacb62b0",
      "name": "Start",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        0,
        320
      ]
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "802f3e0b-77ee-440a-ab2d-d4d5e56b0bac",
              "name": "document",
              "value": "Sample: Annual Operations Report 2025\n1. Overview. The service helped 4,210 customers across 12 branches, up from 3,650 in 2024.\n2. Staffing. Two new branch offices opened in March. Staff turnover fell to 14 percent.\n3. Budget. Spending was 1.8 million dollars against a plan of 2.1 million. The gap came from delayed hiring.\n4. Risks. Fuel prices and road access in the rainy season remain the main risks. Maria Okafor leads the risk review due in June.",
              "type": "string"
            },
            {
              "id": "d0add579-9231-4e00-a813-669f0d9d2afe",
              "name": "reader",
              "value": "Sample: Board members deciding next year's budget",
              "type": "string"
            }
          ]
        },
        "options": {}
      },
      "id": "958f190a-4fdc-495b-a311-6ea070615bd9",
      "name": "Inputs",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        220,
        320
      ],
      "notes": "Paste your own material here: document = The document; reader = Who the summary is for.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "promptType": "define",
        "hasOutputParser": false,
        "text": "=You are a careful research assistant who prepares documents for summary.\n\nYour task is to split the document below into its main parts, in the order they appear. Use the document's own headings where it has them. Where it has none, group the text into parts of similar topic.\n\nThe document:\n{{ $(\"Inputs\").first().json[\"document\"] }}\n\nRules:\n- Cover the whole document. Every paragraph must belong to one part.\n- Do not summarize yet and do not judge the content.\n- Keep between 3 and 10 parts. Join very short sections together.\n\nReturn a numbered list. For each part give:\n- the part's name (the heading, or a short name you give it),\n- the first five words of the part, copied exactly, so a person can find it,\n- one line on what the part covers."
      },
      "id": "58465ea2-f16d-4d7f-a365-ea408b3c2ab5",
      "name": "1. Map the document into parts",
      "type": "@n8n/n8n-nodes-langchain.chainLlm",
      "typeVersion": 1.7,
      "position": [
        480,
        320
      ],
      "notes": "Split the document into its main parts and note what each part covers. Returns: A numbered list of the document's parts, each with where it starts and what it covers.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "promptType": "define",
        "hasOutputParser": false,
        "text": "=You are a careful research assistant writing notes on a document, part by part.\n\nYour task is to write the key points of each part listed in the map below, using the full document.\n\nThe map of parts:\n{{ $(\"1. Map the document into parts\").first().json.text }}\n\nThe full document:\n{{ $(\"Inputs\").first().json[\"document\"] }}\n\nRules:\n- Copy every number, date, amount, percentage and name exactly as the document writes it. Do not round, convert or recalculate.\n- Do not add facts, opinions or background that are not in the document.\n- Keep each point to one sentence. Use at most five points per part.\n- If a part has nothing worth keeping for a summary, write \"No key points\".\n\nReturn one heading per part, in the same order as the map, each followed by its bullet points. Use the part's name as the heading and do not add numbers to the headings, so every number in your answer comes from the document.{{ $(\"Check 1: Every number is in the document\").isExecuted ? \"\\n\\nYour last answer did not pass this check: Every number is in the document. What was wrong: \" + $(\"Check 1: Every number is in the document\").first().json.reason + \"\\nFix that and give your full answer again.\" : \"\" }}"
      },
      "id": "aa3f6254-a054-4cd1-ab98-ba8e0ca21153",
      "name": "2. Summarize each part",
      "type": "@n8n/n8n-nodes-langchain.chainLlm",
      "typeVersion": 1.7,
      "position": [
        740,
        320
      ],
      "notes": "Write the key points of each part, keeping numbers and names exactly as written. Returns: Key points for every part of the document, with numbers and names copied exactly.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "jsCode": "// Every number is in the document\n// Rule: Every number in the answer also appears in the document.\n// Runs with no AI. Returns pass (true or false) and a reason.\nconst runRule = function runRule(rule, value, work, sources) {\n    const norm = (s) => String(s ?? '')\n        .toLowerCase()\n        .replace(/[\\u2018\\u2019\\u201c\\u201d\"'`]/g, '')\n        .replace(/\\s+/g, ' ')\n        .trim();\n    const readTable = (text) => {\n        const lines = String(text ?? '')\n            .split(/\\r?\\n/)\n            .map((l) => l.trim());\n        const tables = [];\n        let cur = null;\n        const cells = (l) => l\n            .replace(/^\\|/, '')\n            .replace(/\\|$/, '')\n            .split('|')\n            .map((c) => c.trim());\n        for (let i = 0; i < lines.length; i++) {\n            const l = lines[i] ?? '';\n            if (!l.startsWith('|')) {\n                cur = null;\n                continue;\n            }\n            if (!cur) {\n                const next = lines[i + 1] ?? '';\n                if (/^\\|?\\s*:?-{2,}/.test(next)) {\n                    cur = { header: cells(l), rows: [] };\n                    tables.push(cur);\n                    i++;\n                }\n                continue;\n            }\n            cur.rows.push(cells(l));\n        }\n        return tables;\n    };\n    // Every table that has this column, with \"None found\" rows left out.\n    // Returns that column's cells from every such table, or null if no table has it.\n    const findColumn = (name) => {\n        let seen = false;\n        const cells = [];\n        for (const t of readTable(work)) {\n            const i = t.header.findIndex((h) => norm(h) === norm(name));\n            if (i < 0)\n                continue;\n            seen = true;\n            for (const r of t.rows) {\n                if (r.some((c) => norm(c) === 'none found'))\n                    continue;\n                cells.push(r[i] ?? '');\n            }\n        }\n        return seen ? cells : null;\n    };\n    const empty = (c) => !c || /^[-\\u2013\\u2014\\s]*$/.test(c);\n    switch (rule) {\n        case 'has_text':\n            return norm(work).includes(norm(value))\n                ? { pass: true, reason: 'Found.' }\n                : { pass: false, reason: `The answer does not include \"${value}\".` };\n        case 'lacks_text':\n            return norm(work).includes(norm(value))\n                ? { pass: false, reason: `The answer includes \"${value}\", which it should not.` }\n                : { pass: true, reason: 'Not found, as it should be.' };\n        case 'max_words': {\n            const n = String(work ?? '')\n                .split(/\\s+/)\n                .filter(Boolean).length;\n            return n <= Number(value)\n                ? { pass: true, reason: `${n} words.` }\n                : { pass: false, reason: `The answer has ${n} words; the limit is ${value}.` };\n        }\n        case 'table_cells_filled': {\n            const problems = [];\n            for (const col of value) {\n                const f = findColumn(col);\n                if (!f) {\n                    problems.push(`No table with a \"${col}\" column was found.`);\n                    continue;\n                }\n                f.forEach((c, i) => {\n                    if (empty(c))\n                        problems.push(`Row ${i + 1} has nothing in \"${col}\".`);\n                });\n            }\n            return problems.length\n                ? { pass: false, reason: problems.join(' ') }\n                : { pass: true, reason: 'Every row is filled in.' };\n        }\n        case 'column_values_in': {\n            const f = findColumn(value.column);\n            if (!f)\n                return { pass: false, reason: `No table with a \"${value.column}\" column was found.` };\n            const allowed = value.allowed.map(norm);\n            const bad = [];\n            f.forEach((c, i) => {\n                if (!allowed.includes(norm(c)))\n                    bad.push(`Row ${i + 1} says \"${c}\".`);\n            });\n            return bad.length\n                ? {\n                    pass: false,\n                    reason: `The \"${value.column}\" column must say ${value.allowed.join(' or ')}. ${bad.join(' ')}`,\n                }\n                : { pass: true, reason: 'Every row uses an allowed word.' };\n        }\n        case 'column_quotes_source': {\n            const f = findColumn(value.column);\n            if (!f)\n                return { pass: false, reason: `No table with a \"${value.column}\" column was found.` };\n            const src = norm(sources[value.input] ?? '');\n            const bad = [];\n            f.forEach((c, i) => {\n                // A quote may be shortened with \"...\"; each piece must be in the source.\n                const pieces = c\n                    .split(/\\.\\.\\.|\\u2026/)\n                    .map(norm)\n                    .filter(Boolean);\n                if (!pieces.length || !pieces.every((p) => src.includes(p)))\n                    bad.push(`Row ${i + 1}: \"${c}\" is not in the source.`);\n            });\n            return bad.length\n                ? { pass: false, reason: bad.join(' ') }\n                : { pass: true, reason: 'Every quote was found in the source.' };\n        }\n        case 'numbers_in_source': {\n            const nums = (s) => (String(s ?? '').match(/\\d[\\d,]*(?:\\.\\d+)?/g) ?? []).map((n) => n.replace(/,/g, ''));\n            const src = new Set(nums(sources[value.input] ?? ''));\n            const missing = [...new Set(nums(work))].filter((n) => !src.has(n));\n            return missing.length\n                ? {\n                    pass: false,\n                    reason: `These numbers are not in the source: ${missing.join(', ')}.`,\n                }\n                : { pass: true, reason: 'Every number was found in the source.' };\n        }\n        default:\n            return { pass: false, reason: `Unknown rule \"${rule}\".` };\n    }\n};\n\nconst work = String($(\"2. Summarize each part\").first().json.text ?? '');\nconst sources = {\n  \"document\": String($(\"Inputs\").first().json[\"document\"] ?? ''),\n};\nconst result = runRule(\"numbers_in_source\", {\"input\":\"document\"}, work, sources);\nreturn [{ json: result }];"
      },
      "id": "0da238fd-cfb8-448c-a16e-139b32217762",
      "name": "Check 1: Every number is in the document",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1000,
        320
      ],
      "notes": "Every number in the answer also appears in the document.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "d7adc554-287d-429d-ac72-21ca287298c3",
              "leftValue": "={{ $json.pass === true }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "1cfaee0d-9167-400e-a645-859f0b47a680",
      "name": "Passed check 1?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1260,
        320
      ],
      "notes": "True = passed. False = did not pass.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "f7b6b89f-96c4-405a-ad8c-92e527f63a90",
              "leftValue": "={{ $runIndex }}",
              "rightValue": 0,
              "operator": {
                "type": "number",
                "operation": "equals"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "1dae04bb-3b19-42a6-a51b-e3158b7f54c8",
      "name": "Try again? (check 1)",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1260,
        520
      ],
      "notes": "True = first failure: send back to \"2. Summarize each part\" once. False = failed twice: flag it.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "8eced4fe-77db-46df-a498-9610250a4661",
              "name": "flag",
              "value": "=Every number is in the document: did not pass. {{ $(\"Check 1: Every number is in the document\").first().json.reason }}",
              "type": "string"
            }
          ]
        },
        "options": {}
      },
      "id": "f75cc1aa-2758-4573-aaa1-ee84271cbdc5",
      "name": "Flag check 1",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        1390,
        700
      ],
      "notes": "Notes the problem and carries on. The note shows in Result.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "promptType": "define",
        "hasOutputParser": false,
        "text": "=You are a writer who briefs busy readers on long documents.\n\nYour task is to turn the part-by-part notes below into one summary for this reader: {{ $(\"Inputs\").first().json[\"reader\"] }}\n\nPart-by-part notes:\n{{ $(\"2. Summarize each part\").first().json.text }}\n\nRules:\n- Lead with what matters most to this reader, not with the order of the document.\n- Group points by theme. Join points that say the same thing.\n- Keep every number and name exactly as it appears in the notes.\n- Do not add advice, opinions or conclusions the document does not make.\n\nReturn markdown in this shape:\n## In short\n(two or three sentences)\n## Key points\n(five to eight bullets, most important first)\n## What it means for you\n(two or three bullets for this reader, using only what the document says)"
      },
      "id": "8b2eb36c-41b2-4963-af6b-05c64ba0937d",
      "name": "3. Merge into one summary",
      "type": "@n8n/n8n-nodes-langchain.chainLlm",
      "typeVersion": 1.7,
      "position": [
        1520,
        320
      ],
      "notes": "Combine the part notes into one summary written for the reader. Returns: A first full summary in three short sections, written for the reader.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "promptType": "define",
        "hasOutputParser": false,
        "text": "=You are an editor who makes summaries short and plain.\n\nYour task is to tighten the summary below to 300 words or fewer, for this reader: {{ $(\"Inputs\").first().json[\"reader\"] }}\n\nSummary to tighten:\n{{ $(\"3. Merge into one summary\").first().json.text }}\n\nRules:\n- Keep the three headings: In short, Key points, What it means for you.\n- Cut repeats, filler and long phrases first. Cut whole points last, starting with the least important.\n- Keep every number and name that stays in exactly as written.\n- Use plain words and short sentences. Do not add anything new.\n\nReturn only the tightened summary in markdown, with no note about what you changed.{{ $(\"Check 2: The summary is 300 words or fewer\").isExecuted ? \"\\n\\nYour last answer did not pass this check: The summary is 300 words or fewer. What was wrong: \" + $(\"Check 2: The summary is 300 words or fewer\").first().json.reason + \"\\nFix that and give your full answer again.\" : \"\" }}{{ $(\"Check 3: Every number in the summary is in the document\").isExecuted ? \"\\n\\nYour last answer did not pass this check: Every number in the summary is in the document. What was wrong: \" + $(\"Check 3: Every number in the summary is in the document\").first().json.reason + \"\\nFix that and give your full answer again.\" : \"\" }}{{ $(\"Check 4: Every name matches the document\").isExecuted ? \"\\n\\nYour last answer did not pass this check: Every name matches the document. What was wrong: \" + $(\"Check 4: Every name matches the document\").first().json.text + \"\\nFix that and give your full answer again.\" : \"\" }}"
      },
      "id": "93838b25-e058-4bdc-a7de-918f4aa634be",
      "name": "4. Tighten the summary",
      "type": "@n8n/n8n-nodes-langchain.chainLlm",
      "typeVersion": 1.7,
      "position": [
        1780,
        320
      ],
      "notes": "Cut the summary to 300 words or fewer without losing a key fact. Returns: The final summary, 300 words or fewer.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "jsCode": "// The summary is 300 words or fewer\n// Rule: The answer is no longer than 300 words.\n// Runs with no AI. Returns pass (true or false) and a reason.\nconst runRule = function runRule(rule, value, work, sources) {\n    const norm = (s) => String(s ?? '')\n        .toLowerCase()\n        .replace(/[\\u2018\\u2019\\u201c\\u201d\"'`]/g, '')\n        .replace(/\\s+/g, ' ')\n        .trim();\n    const readTable = (text) => {\n        const lines = String(text ?? '')\n            .split(/\\r?\\n/)\n            .map((l) => l.trim());\n        const tables = [];\n        let cur = null;\n        const cells = (l) => l\n            .replace(/^\\|/, '')\n            .replace(/\\|$/, '')\n            .split('|')\n            .map((c) => c.trim());\n        for (let i = 0; i < lines.length; i++) {\n            const l = lines[i] ?? '';\n            if (!l.startsWith('|')) {\n                cur = null;\n                continue;\n            }\n            if (!cur) {\n                const next = lines[i + 1] ?? '';\n                if (/^\\|?\\s*:?-{2,}/.test(next)) {\n                    cur = { header: cells(l), rows: [] };\n                    tables.push(cur);\n                    i++;\n                }\n                continue;\n            }\n            cur.rows.push(cells(l));\n        }\n        return tables;\n    };\n    // Every table that has this column, with \"None found\" rows left out.\n    // Returns that column's cells from every such table, or null if no table has it.\n    const findColumn = (name) => {\n        let seen = false;\n        const cells = [];\n        for (const t of readTable(work)) {\n            const i = t.header.findIndex((h) => norm(h) === norm(name));\n            if (i < 0)\n                continue;\n            seen = true;\n            for (const r of t.rows) {\n                if (r.some((c) => norm(c) === 'none found'))\n                    continue;\n                cells.push(r[i] ?? '');\n            }\n        }\n        return seen ? cells : null;\n    };\n    const empty = (c) => !c || /^[-\\u2013\\u2014\\s]*$/.test(c);\n    switch (rule) {\n        case 'has_text':\n            return norm(work).includes(norm(value))\n                ? { pass: true, reason: 'Found.' }\n                : { pass: false, reason: `The answer does not include \"${value}\".` };\n        case 'lacks_text':\n            return norm(work).includes(norm(value))\n                ? { pass: false, reason: `The answer includes \"${value}\", which it should not.` }\n                : { pass: true, reason: 'Not found, as it should be.' };\n        case 'max_words': {\n            const n = String(work ?? '')\n                .split(/\\s+/)\n                .filter(Boolean).length;\n            return n <= Number(value)\n                ? { pass: true, reason: `${n} words.` }\n                : { pass: false, reason: `The answer has ${n} words; the limit is ${value}.` };\n        }\n        case 'table_cells_filled': {\n            const problems = [];\n            for (const col of value) {\n                const f = findColumn(col);\n                if (!f) {\n                    problems.push(`No table with a \"${col}\" column was found.`);\n                    continue;\n                }\n                f.forEach((c, i) => {\n                    if (empty(c))\n                        problems.push(`Row ${i + 1} has nothing in \"${col}\".`);\n                });\n            }\n            return problems.length\n                ? { pass: false, reason: problems.join(' ') }\n                : { pass: true, reason: 'Every row is filled in.' };\n        }\n        case 'column_values_in': {\n            const f = findColumn(value.column);\n            if (!f)\n                return { pass: false, reason: `No table with a \"${value.column}\" column was found.` };\n            const allowed = value.allowed.map(norm);\n            const bad = [];\n            f.forEach((c, i) => {\n                if (!allowed.includes(norm(c)))\n                    bad.push(`Row ${i + 1} says \"${c}\".`);\n            });\n            return bad.length\n                ? {\n                    pass: false,\n                    reason: `The \"${value.column}\" column must say ${value.allowed.join(' or ')}. ${bad.join(' ')}`,\n                }\n                : { pass: true, reason: 'Every row uses an allowed word.' };\n        }\n        case 'column_quotes_source': {\n            const f = findColumn(value.column);\n            if (!f)\n                return { pass: false, reason: `No table with a \"${value.column}\" column was found.` };\n            const src = norm(sources[value.input] ?? '');\n            const bad = [];\n            f.forEach((c, i) => {\n                // A quote may be shortened with \"...\"; each piece must be in the source.\n                const pieces = c\n                    .split(/\\.\\.\\.|\\u2026/)\n                    .map(norm)\n                    .filter(Boolean);\n                if (!pieces.length || !pieces.every((p) => src.includes(p)))\n                    bad.push(`Row ${i + 1}: \"${c}\" is not in the source.`);\n            });\n            return bad.length\n                ? { pass: false, reason: bad.join(' ') }\n                : { pass: true, reason: 'Every quote was found in the source.' };\n        }\n        case 'numbers_in_source': {\n            const nums = (s) => (String(s ?? '').match(/\\d[\\d,]*(?:\\.\\d+)?/g) ?? []).map((n) => n.replace(/,/g, ''));\n            const src = new Set(nums(sources[value.input] ?? ''));\n            const missing = [...new Set(nums(work))].filter((n) => !src.has(n));\n            return missing.length\n                ? {\n                    pass: false,\n                    reason: `These numbers are not in the source: ${missing.join(', ')}.`,\n                }\n                : { pass: true, reason: 'Every number was found in the source.' };\n        }\n        default:\n            return { pass: false, reason: `Unknown rule \"${rule}\".` };\n    }\n};\n\nconst work = String($(\"4. Tighten the summary\").first().json.text ?? '');\nconst sources = {\n\n};\nconst result = runRule(\"max_words\", 300, work, sources);\nreturn [{ json: result }];"
      },
      "id": "3f95c428-70e9-4405-ab62-30aea8e05a2b",
      "name": "Check 2: The summary is 300 words or fewer",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2040,
        320
      ],
      "notes": "The answer is no longer than 300 words.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "d8adc6e7-277d-410a-ad72-235d27729730",
              "leftValue": "={{ $json.pass === true }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "9d027352-1160-4ac9-a643-47088b4aae97",
      "name": "Passed check 2?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        2300,
        320
      ],
      "notes": "True = passed. False = did not pass.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "f6b6b70c-97c4-41ed-ac8c-915228f63c23",
              "leftValue": "={{ $runIndex }}",
              "rightValue": 0,
              "operator": {
                "type": "number",
                "operation": "equals"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "7db557a0-631b-4035-ad19-65860b77cf83",
      "name": "Try again? (check 2)",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        2300,
        520
      ],
      "notes": "True = first failure: send back to \"4. Tighten the summary\" once. False = failed twice: flag it.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "8fced691-76db-454c-a598-97a3240a44ce",
              "name": "flag",
              "value": "=The summary is 300 words or fewer: did not pass. {{ $(\"Check 2: The summary is 300 words or fewer\").first().json.reason }}",
              "type": "string"
            }
          ]
        },
        "options": {}
      },
      "id": "f65cc017-2858-4706-ada1-f33d241cb90c",
      "name": "Flag check 2",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        2430,
        700
      ],
      "notes": "Notes the problem and carries on. The note shows in Result.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "jsCode": "// Every number in the summary is in the document\n// Rule: Every number in the answer also appears in the document.\n// Runs with no AI. Returns pass (true or false) and a reason.\nconst runRule = function runRule(rule, value, work, sources) {\n    const norm = (s) => String(s ?? '')\n        .toLowerCase()\n        .replace(/[\\u2018\\u2019\\u201c\\u201d\"'`]/g, '')\n        .replace(/\\s+/g, ' ')\n        .trim();\n    const readTable = (text) => {\n        const lines = String(text ?? '')\n            .split(/\\r?\\n/)\n            .map((l) => l.trim());\n        const tables = [];\n        let cur = null;\n        const cells = (l) => l\n            .replace(/^\\|/, '')\n            .replace(/\\|$/, '')\n            .split('|')\n            .map((c) => c.trim());\n        for (let i = 0; i < lines.length; i++) {\n            const l = lines[i] ?? '';\n            if (!l.startsWith('|')) {\n                cur = null;\n                continue;\n            }\n            if (!cur) {\n                const next = lines[i + 1] ?? '';\n                if (/^\\|?\\s*:?-{2,}/.test(next)) {\n                    cur = { header: cells(l), rows: [] };\n                    tables.push(cur);\n                    i++;\n                }\n                continue;\n            }\n            cur.rows.push(cells(l));\n        }\n        return tables;\n    };\n    // Every table that has this column, with \"None found\" rows left out.\n    // Returns that column's cells from every such table, or null if no table has it.\n    const findColumn = (name) => {\n        let seen = false;\n        const cells = [];\n        for (const t of readTable(work)) {\n            const i = t.header.findIndex((h) => norm(h) === norm(name));\n            if (i < 0)\n                continue;\n            seen = true;\n            for (const r of t.rows) {\n                if (r.some((c) => norm(c) === 'none found'))\n                    continue;\n                cells.push(r[i] ?? '');\n            }\n        }\n        return seen ? cells : null;\n    };\n    const empty = (c) => !c || /^[-\\u2013\\u2014\\s]*$/.test(c);\n    switch (rule) {\n        case 'has_text':\n            return norm(work).includes(norm(value))\n                ? { pass: true, reason: 'Found.' }\n                : { pass: false, reason: `The answer does not include \"${value}\".` };\n        case 'lacks_text':\n            return norm(work).includes(norm(value))\n                ? { pass: false, reason: `The answer includes \"${value}\", which it should not.` }\n                : { pass: true, reason: 'Not found, as it should be.' };\n        case 'max_words': {\n            const n = String(work ?? '')\n                .split(/\\s+/)\n                .filter(Boolean).length;\n            return n <= Number(value)\n                ? { pass: true, reason: `${n} words.` }\n                : { pass: false, reason: `The answer has ${n} words; the limit is ${value}.` };\n        }\n        case 'table_cells_filled': {\n            const problems = [];\n            for (const col of value) {\n                const f = findColumn(col);\n                if (!f) {\n                    problems.push(`No table with a \"${col}\" column was found.`);\n                    continue;\n                }\n                f.forEach((c, i) => {\n                    if (empty(c))\n                        problems.push(`Row ${i + 1} has nothing in \"${col}\".`);\n                });\n            }\n            return problems.length\n                ? { pass: false, reason: problems.join(' ') }\n                : { pass: true, reason: 'Every row is filled in.' };\n        }\n        case 'column_values_in': {\n            const f = findColumn(value.column);\n            if (!f)\n                return { pass: false, reason: `No table with a \"${value.column}\" column was found.` };\n            const allowed = value.allowed.map(norm);\n            const bad = [];\n            f.forEach((c, i) => {\n                if (!allowed.includes(norm(c)))\n                    bad.push(`Row ${i + 1} says \"${c}\".`);\n            });\n            return bad.length\n                ? {\n                    pass: false,\n                    reason: `The \"${value.column}\" column must say ${value.allowed.join(' or ')}. ${bad.join(' ')}`,\n                }\n                : { pass: true, reason: 'Every row uses an allowed word.' };\n        }\n        case 'column_quotes_source': {\n            const f = findColumn(value.column);\n            if (!f)\n                return { pass: false, reason: `No table with a \"${value.column}\" column was found.` };\n            const src = norm(sources[value.input] ?? '');\n            const bad = [];\n            f.forEach((c, i) => {\n                // A quote may be shortened with \"...\"; each piece must be in the source.\n                const pieces = c\n                    .split(/\\.\\.\\.|\\u2026/)\n                    .map(norm)\n                    .filter(Boolean);\n                if (!pieces.length || !pieces.every((p) => src.includes(p)))\n                    bad.push(`Row ${i + 1}: \"${c}\" is not in the source.`);\n            });\n            return bad.length\n                ? { pass: false, reason: bad.join(' ') }\n                : { pass: true, reason: 'Every quote was found in the source.' };\n        }\n        case 'numbers_in_source': {\n            const nums = (s) => (String(s ?? '').match(/\\d[\\d,]*(?:\\.\\d+)?/g) ?? []).map((n) => n.replace(/,/g, ''));\n            const src = new Set(nums(sources[value.input] ?? ''));\n            const missing = [...new Set(nums(work))].filter((n) => !src.has(n));\n            return missing.length\n                ? {\n                    pass: false,\n                    reason: `These numbers are not in the source: ${missing.join(', ')}.`,\n                }\n                : { pass: true, reason: 'Every number was found in the source.' };\n        }\n        default:\n            return { pass: false, reason: `Unknown rule \"${rule}\".` };\n    }\n};\n\nconst work = String($(\"4. Tighten the summary\").first().json.text ?? '');\nconst sources = {\n  \"document\": String($(\"Inputs\").first().json[\"document\"] ?? ''),\n};\nconst result = runRule(\"numbers_in_source\", {\"input\":\"document\"}, work, sources);\nreturn [{ json: result }];"
      },
      "id": "127bafd3-7a9c-4904-a598-1d8528aba2e6",
      "name": "Check 3: Every number in the summary is in the document",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2560,
        320
      ],
      "notes": "Every number in the answer also appears in the document.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "d9adc87a-267d-4f77-aa72-1ea42a729be9",
              "leftValue": "={{ $json.pass === true }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "9d0034bb-1162-4960-a641-08718b4ced2e",
      "name": "Passed check 3?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        2820,
        320
      ],
      "notes": "True = passed. False = did not pass.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "f9b6bbc5-94c4-4d34-ab8c-8fbf29f63db6",
              "leftValue": "={{ $runIndex }}",
              "rightValue": 0,
              "operator": {
                "type": "number",
                "operation": "equals"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "29b294cd-d71e-4568-a916-89836f7aab86",
      "name": "Try again? (check 3)",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        2820,
        520
      ],
      "notes": "True = first failure: send back to \"4. Tighten the summary\" once. False = failed twice: flag it.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "8cced1d8-79db-4a05-a698-9936230a433b",
              "name": "flag",
              "value": "=Every number in the summary is in the document: did not pass. {{ $(\"Check 3: Every number in the summary is in the document\").first().json.reason }}",
              "type": "string"
            }
          ]
        },
        "options": {}
      },
      "id": "f55cbe84-2958-4899-aca1-f1aa251cba9f",
      "name": "Flag check 3",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        2950,
        700
      ],
      "notes": "Notes the problem and carries on. The note shows in Result.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "promptType": "define",
        "hasOutputParser": false,
        "text": "=You are a careful reviewer. You do not fix or rewrite the work; you only decide whether it passes one check.\n\nThe check: Every name matches the document\nCheck every name of a person, place, organization, program and document in the summary against the source document below. Is each one spelled and used exactly as the source has it, and does each one appear in the source?\n\nSource document:\n{{ $(\"Inputs\").first().json[\"document\"] }}\n\nThe work to check (the answer from Step 4 (Tighten the summary)):\n{{ $(\"4. Tighten the summary\").first().json.text }}\n\nReply with exactly PASS or FAIL on the first line. On the second line, say in one sentence why. If it fails, name the exact item or line that fails. Do not add anything else."
      },
      "id": "0f89126c-4102-462d-a218-0aba202fcd03",
      "name": "Check 4: Every name matches the document",
      "type": "@n8n/n8n-nodes-langchain.chainLlm",
      "typeVersion": 1.7,
      "position": [
        3080,
        320
      ],
      "notes": "Answers PASS or FAIL. Check every name of a person, place, organization, program and document in the summary against the source document below. Is each one spelled and used exactly as the source has it, and does each one appear in the source?",
      "notesInFlow": true
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "daadca0d-257d-4de4-ab72-203729729a56",
              "leftValue": "={{ String($json.text ?? '').trim().toUpperCase().startsWith('PASS') }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "9d06f080-915b-441b-a63e-c9da8b3ba40d",
      "name": "Passed check 4?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        3340,
        320
      ],
      "notes": "True = passed. False = did not pass.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "f8b6ba32-95c4-4ec7-aa8c-8e2c2af63f49",
              "leftValue": "={{ $runIndex }}",
              "rightValue": 0,
              "operator": {
                "type": "number",
                "operation": "equals"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "89b9e7b2-bf20-4e37-a127-df3cef86adf9",
      "name": "Try again? (check 4)",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        3340,
        520
      ],
      "notes": "True = first failure: send back to \"4. Tighten the summary\" once. False = failed twice: flag it.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "8dced36b-78db-4872-a798-9ac9220a41a8",
              "name": "flag",
              "value": "=Every name matches the document: did not pass. {{ $(\"Check 4: Every name matches the document\").first().json.text }}",
              "type": "string"
            }
          ]
        },
        "options": {}
      },
      "id": "f45cbcf1-2a58-4a2c-a7a1-e9cb221cb5e6",
      "name": "Flag check 4",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        3470,
        700
      ],
      "notes": "Notes the problem and carries on. The note shows in Result.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "content": "**Check 5: check by hand**\nWould the document's author agree that the summary gives a fair picture, with nothing important left out and nothing made stronger or weaker?\n\nIf not: flag it and carry on. This check is also listed in Result.",
        "height": 200,
        "width": 240,
        "color": 3
      },
      "id": "eb3e4a47-f585-4bdc-afb6-f9757f82b33a",
      "name": "Check 5 (by hand)",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        3320,
        70
      ]
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "3878784d-38ba-499a-ab4f-d46b0d853688",
              "name": "result",
              "value": "={{ $(\"4. Tighten the summary\").first().json.text }}",
              "type": "string"
            },
            {
              "id": "40d7a0d2-34ce-4c63-ad32-c900e0f90eb9",
              "name": "flagged",
              "value": "={{ [$(\"Flag check 1\").isExecuted ? $(\"Flag check 1\").first().json.flag : \"\", $(\"Flag check 2\").isExecuted ? $(\"Flag check 2\").first().json.flag : \"\", $(\"Flag check 3\").isExecuted ? $(\"Flag check 3\").first().json.flag : \"\", $(\"Flag check 4\").isExecuted ? $(\"Flag check 4\").first().json.flag : \"\"].filter(x => x).join(\"\\n\") || \"None\" }}",
              "type": "string"
            },
            {
              "id": "50bc3a40-c819-4699-a91a-0c5eb3f69f7f",
              "name": "check_by_hand",
              "value": "- Would the document's author agree that the summary gives a fair picture, with nothing important left out and nothing made stronger or weaker?",
              "type": "string"
            }
          ]
        },
        "options": {}
      },
      "id": "10c23936-7f0b-47bf-a5bf-46bc5ef020c5",
      "name": "Result",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        3600,
        320
      ],
      "notes": "The final answer, anything flagged, and the checks left for you.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "options": {}
      },
      "id": "1acf5460-7544-4805-a33b-6d8e15f1b10b",
      "name": "Chat model",
      "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
      "typeVersion": 1.2,
      "position": [
        480,
        840
      ],
      "notes": "Attach your credential here, or swap in any chat model node.",
      "notesInFlow": true
    }
  ],
  "connections": {
    "Start": {
      "main": [
        [
          {
            "node": "Inputs",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Inputs": {
      "main": [
        [
          {
            "node": "1. Map the document into parts",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "1. Map the document into parts": {
      "main": [
        [
          {
            "node": "2. Summarize each part",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "2. Summarize each part": {
      "main": [
        [
          {
            "node": "Check 1: Every number is in the document",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check 1: Every number is in the document": {
      "main": [
        [
          {
            "node": "Passed check 1?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Passed check 1?": {
      "main": [
        [
          {
            "node": "3. Merge into one summary",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Try again? (check 1)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Try again? (check 1)": {
      "main": [
        [
          {
            "node": "2. Summarize each part",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Flag check 1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Flag check 1": {
      "main": [
        [
          {
            "node": "3. Merge into one summary",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "3. Merge into one summary": {
      "main": [
        [
          {
            "node": "4. Tighten the summary",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "4. Tighten the summary": {
      "main": [
        [
          {
            "node": "Check 2: The summary is 300 words or fewer",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check 2: The summary is 300 words or fewer": {
      "main": [
        [
          {
            "node": "Passed check 2?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Passed check 2?": {
      "main": [
        [
          {
            "node": "Check 3: Every number in the summary is in the document",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Try again? (check 2)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Try again? (check 2)": {
      "main": [
        [
          {
            "node": "4. Tighten the summary",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Flag check 2",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Flag check 2": {
      "main": [
        [
          {
            "node": "Check 3: Every number in the summary is in the document",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check 3: Every number in the summary is in the document": {
      "main": [
        [
          {
            "node": "Passed check 3?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Passed check 3?": {
      "main": [
        [
          {
            "node": "Check 4: Every name matches the document",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Try again? (check 3)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Try again? (check 3)": {
      "main": [
        [
          {
            "node": "4. Tighten the summary",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Flag check 3",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Flag check 3": {
      "main": [
        [
          {
            "node": "Check 4: Every name matches the document",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check 4: Every name matches the document": {
      "main": [
        [
          {
            "node": "Passed check 4?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Passed check 4?": {
      "main": [
        [
          {
            "node": "Result",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Try again? (check 4)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Try again? (check 4)": {
      "main": [
        [
          {
            "node": "4. Tighten the summary",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Flag check 4",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Flag check 4": {
      "main": [
        [
          {
            "node": "Result",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Chat model": {
      "ai_languageModel": [
        [
          {
            "node": "1. Map the document into parts",
            "type": "ai_languageModel",
            "index": 0
          },
          {
            "node": "2. Summarize each part",
            "type": "ai_languageModel",
            "index": 0
          },
          {
            "node": "3. Merge into one summary",
            "type": "ai_languageModel",
            "index": 0
          },
          {
            "node": "4. Tighten the summary",
            "type": "ai_languageModel",
            "index": 0
          },
          {
            "node": "Check 4: Every name matches the document",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    }
  },
  "pinData": {},
  "settings": {
    "executionOrder": "v1"
  },
  "meta": {
    "template": "long-document-summary"
  }
}
