{
  "name": "Review a draft before you send it",
  "nodes": [
    {
      "parameters": {
        "content": "## Review a draft before you send it\nGive a draft one pass for writing and one pass for substance, then hand back a short ranked list of fixes, so you know what must change before it goes out.\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 row of the table has one of \"sourced\" or \"unsourced\" in the Status column. If it fails: try once more, then flag.\n- Check 2 (a plain rule, run automatically with no AI): Every row of the table has one of \"must\", \"should\" or \"nice\" in the Rating column. If it fails: try once more, then flag.\n- Check 3 (a plain rule, run automatically with no AI): Every quote in the Where (quote) column appears word for word in the draft. If it fails: flag it and carry on.\n- Check 4 (a second AI prompt answers pass or fail): Look at every fix rated must. Would sending the draft below without that fix really risk misleading the reader, losing the ask, or causing harm? Answer FAIL if any must fix is only a style preference. If it fails: try once more, then flag.\n- Check 5 (you check it by hand): Read the must fixes. Do you agree with each one, and do you know how to make it? 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": "70736279-f585-4672-a35e-8187f9f24960",
      "name": "About this workflow",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -40,
        -540
      ]
    },
    {
      "parameters": {},
      "id": "357c5431-b790-4286-a6b8-f53739db26fc",
      "name": "Start",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        0,
        320
      ]
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "218fc339-8db4-405a-ae25-bb97bd7f5d38",
              "name": "draft",
              "value": "Sample: Hi Jordan,\nFollowing our call, we propose to expand the pilot to all 8 sites by January. The pilot cut wait times by 40 percent and staff say the new form is easier. We will need two more staff and a budget of about 60,000 dollars. We think this is the obvious next step and hope you agree.\nBest, Sam",
              "type": "string"
            },
            {
              "id": "159b9415-0842-4ff6-ae33-c3379973e2b8",
              "name": "context",
              "value": "Sample: Department head, deciding whether to fund the pilot expansion",
              "type": "string"
            },
            {
              "id": "d99540ec-cc35-451b-a1dd-49c669415ced",
              "name": "sources",
              "value": "Sample: Pilot report, Sept: average wait time fell from 50 to 30 minutes at the 3 pilot sites. Staff survey: 11 of 14 staff prefer the new form.",
              "type": "string"
            }
          ]
        },
        "options": {}
      },
      "id": "2c37dd16-6ecc-4dab-a589-3a804275b5b5",
      "name": "Inputs",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        220,
        320
      ],
      "notes": "Paste your own material here: draft = The draft; context = Who will read it and what it is for; sources = Sources or notes the draft relies on (optional).",
      "notesInFlow": true
    },
    {
      "parameters": {
        "promptType": "define",
        "hasOutputParser": false,
        "text": "=You are a senior editor who reviews drafts for busy readers.\n\nYour task is to review only the writing of the draft below: clarity, order, length, tone and wording. Do not judge whether the facts are right; that is a separate pass.\n\nWho will read it and why: {{ $(\"Inputs\").first().json[\"context\"] }}\n\nThe draft:\n{{ $(\"Inputs\").first().json[\"draft\"] }}\n\nLook for:\n- the main point or ask arriving late or not at all,\n- sentences that are long, vague or hard to follow,\n- tone that does not suit this reader (too casual, too pushy, too stiff),\n- repeated points, filler and jargon this reader may not know.\n\nRules:\n- Point to the exact words, quoted from the draft.\n- Suggest a concrete fix for each problem, not just \"make this clearer\".\n- Do not rewrite the whole draft. Do not comment on facts or numbers.\n- Find no more than 10 problems; pick the ones that matter most for this reader.\n\nReturn one markdown table and nothing else:\n| # | Where (quote) | Problem | Suggested fix |"
      },
      "id": "e2e15689-e93f-41e6-a011-b0dbd8dbf180",
      "name": "1. Writing pass",
      "type": "@n8n/n8n-nodes-langchain.chainLlm",
      "typeVersion": 1.7,
      "position": [
        480,
        320
      ],
      "notes": "Check the draft for clarity, order, length and tone for its reader. Returns: A table of writing problems, each with the exact words and a suggested fix.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "promptType": "define",
        "hasOutputParser": false,
        "text": "=You are a fact checker who reviews drafts before they go to decision makers.\n\nYour task is to list every claim in the draft that a reader might rely on (numbers, results, promises, comparisons, statements about people or plans), and mark whether each one is backed.\n\nWho will read it and why: {{ $(\"Inputs\").first().json[\"context\"] }}\n\nThe draft:\n{{ $(\"Inputs\").first().json[\"draft\"] }}\n\nSources or notes the draft relies on (may be empty):\n{{ $(\"Inputs\").first().json[\"sources\"] }}\n\nRules:\n- Mark a claim \"sourced\" only if the sources above support it, or the draft itself names where it comes from. Otherwise mark it \"unsourced\".\n- If a number in the draft differs from the sources, mark it \"unsourced\" and say what the source says.\n- Also note any gap in the reasoning, such as an ask with no reason given, or a cost with no basis.\n- Do not judge the writing style. Do not invent sources.\n\nReturn one markdown table, then a short list:\n| # | Claim (quote) | Status | Backing or what is missing |\nThe Status column must say exactly sourced or unsourced.\n\nGaps in reasoning:\n(bullets, or \"None\"){{ $(\"Check 1: Each claim is marked sourced or unsourced\").isExecuted ? \"\\n\\nYour last answer did not pass this check: Each claim is marked sourced or unsourced. What was wrong: \" + $(\"Check 1: Each claim is marked sourced or unsourced\").first().json.reason + \"\\nFix that and give your full answer again.\" : \"\" }}"
      },
      "id": "7248bbc2-8647-4a29-a5f9-c1e482904813",
      "name": "2. Substance pass",
      "type": "@n8n/n8n-nodes-langchain.chainLlm",
      "typeVersion": 1.7,
      "position": [
        740,
        320
      ],
      "notes": "List each claim in the draft and mark whether it is backed by a source. Returns: A table of claims marked sourced or unsourced, and a list of gaps in the reasoning.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "jsCode": "// Each claim is marked sourced or unsourced\n// Rule: Every row of the table has one of \"sourced\" or \"unsourced\" in the Status column.\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. Substance pass\").first().json.text ?? '');\nconst sources = {\n\n};\nconst result = runRule(\"column_values_in\", {\"column\":\"Status\",\"allowed\":[\"sourced\",\"unsourced\"]}, work, sources);\nreturn [{ json: result }];"
      },
      "id": "25af1c3d-b465-452c-afd5-ceafa38c4716",
      "name": "Check 1: Each claim is marked sourced or unsourced",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1000,
        320
      ],
      "notes": "Every row of the table has one of \"sourced\" or \"unsourced\" in the Status column.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "54d8d2e8-7deb-42cd-a38b-806a23d43af7",
              "leftValue": "={{ $json.pass === true }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "cd739a19-a2a0-451e-aa78-01ff09cfea1c",
      "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": "65b0e9f3-1577-458a-aaad-a38527053fe4",
              "leftValue": "={{ $runIndex }}",
              "rightValue": 0,
              "operator": {
                "type": "number",
                "operation": "equals"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "7e0baaa7-b74b-4a56-ac10-4a7562057714",
      "name": "Try again? (check 1)",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1260,
        520
      ],
      "notes": "True = first failure: send back to \"2. Substance pass\" once. False = failed twice: flag it.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "4397fcc2-c872-49cf-af72-1a3060594695",
              "name": "flag",
              "value": "=Each claim is marked sourced or unsourced: did not pass. {{ $(\"Check 1: Each claim is marked sourced or unsourced\").first().json.reason }}",
              "type": "string"
            }
          ]
        },
        "options": {}
      },
      "id": "9d36d71e-969f-4a23-ae0d-ad64e65d0dc9",
      "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 trusted colleague giving a final review before a draft is sent.\n\nYour task is to combine the two reviews below into one ranked list of fixes for the writer.\n\nWho will read it and why: {{ $(\"Inputs\").first().json[\"context\"] }}\n\nWriting review:\n{{ $(\"1. Writing pass\").first().json.text }}\n\nSubstance review:\n{{ $(\"2. Substance pass\").first().json.text }}\n\nRate each fix with exactly one word:\n- must: sending without this fix could mislead the reader, lose the ask, or cause harm,\n- should: the draft is clearly weaker without it,\n- nice: a small polish.\n\nRules:\n- Every unsourced claim becomes a fix (add a source, soften the claim or remove it).\n- Put all must fixes first, then should, then nice.\n- Join duplicates. Keep the list to 12 fixes or fewer.\n- Do not rewrite the draft and do not add new problems that neither review found.\n\nReturn one markdown table, then one line:\n| Rank | Rating | Fix | Where (quote) | Why it matters |\nReady to send after the must fixes? (yes or no, and one sentence why){{ $(\"Check 2: Each fix is rated must, should or nice\").isExecuted ? \"\\n\\nYour last answer did not pass this check: Each fix is rated must, should or nice. What was wrong: \" + $(\"Check 2: Each fix is rated must, should or nice\").first().json.reason + \"\\nFix that and give your full answer again.\" : \"\" }}{{ $(\"Check 4: The must fixes are truly must\").isExecuted ? \"\\n\\nYour last answer did not pass this check: The must fixes are truly must. What was wrong: \" + $(\"Check 4: The must fixes are truly must\").first().json.text + \"\\nFix that and give your full answer again.\" : \"\" }}"
      },
      "id": "68a4ffce-bb84-4843-adde-166490085231",
      "name": "3. Ranked fix list",
      "type": "@n8n/n8n-nodes-langchain.chainLlm",
      "typeVersion": 1.7,
      "position": [
        1520,
        320
      ],
      "notes": "Combine both passes into one short list of fixes, most important first. Returns: One ranked table of fixes rated must, should or nice, and a yes or no on whether it is ready to send.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "jsCode": "// Each fix is rated must, should or nice\n// Rule: Every row of the table has one of \"must\", \"should\" or \"nice\" in the Rating column.\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($(\"3. Ranked fix list\").first().json.text ?? '');\nconst sources = {\n\n};\nconst result = runRule(\"column_values_in\", {\"column\":\"Rating\",\"allowed\":[\"must\",\"should\",\"nice\"]}, work, sources);\nreturn [{ json: result }];"
      },
      "id": "2526e72d-ce63-47b2-a0b3-62c3fc933840",
      "name": "Check 2: Each fix is rated must, should or nice",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1780,
        320
      ],
      "notes": "Every row of the table has one of \"must\", \"should\" or \"nice\" in the Rating column.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "55d8d47b-7ceb-413a-a48b-81fd22d43964",
              "leftValue": "={{ $json.pass === true }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "4d7b1f5e-2298-4fd9-aa75-c36809d228b3",
      "name": "Passed check 2?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        2040,
        320
      ],
      "notes": "True = passed. False = did not pass.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "64b0e860-1677-471d-a9ad-a1f228054177",
              "leftValue": "={{ $runIndex }}",
              "rightValue": 0,
              "operator": {
                "type": "number",
                "operation": "equals"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "de12fd8c-1f4e-4ca5-a40d-cce6e1fdf1cf",
      "name": "Try again? (check 2)",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        2040,
        520
      ],
      "notes": "True = first failure: send back to \"3. Ranked fix list\" once. False = failed twice: flag it.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "4497fe55-c772-483c-a072-1bc35f594502",
              "name": "flag",
              "value": "=Each fix is rated must, should or nice: did not pass. {{ $(\"Check 2: Each fix is rated must, should or nice\").first().json.reason }}",
              "type": "string"
            }
          ]
        },
        "options": {}
      },
      "id": "9c36d58b-979f-4bb6-a10d-b21de35d0910",
      "name": "Flag check 2",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        2170,
        700
      ],
      "notes": "Notes the problem and carries on. The note shows in Result.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "jsCode": "// Every quoted place is really in the draft\n// Rule: Every quote in the Where (quote) column appears word for word in the draft.\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($(\"3. Ranked fix list\").first().json.text ?? '');\nconst sources = {\n  \"draft\": String($(\"Inputs\").first().json[\"draft\"] ?? ''),\n};\nconst result = runRule(\"column_quotes_source\", {\"column\":\"Where (quote)\",\"input\":\"draft\"}, work, sources);\nreturn [{ json: result }];"
      },
      "id": "0d6717fe-cb9d-4327-ac44-f668761888e1",
      "name": "Check 3: Every quoted place is really in the draft",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2300,
        320
      ],
      "notes": "Every quote in the Where (quote) column appears word for word in the draft.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "56d8d60e-7beb-4fa7-a18b-7d4425d43e1d",
              "leftValue": "={{ $json.pass === true }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "4d78e0c7-229b-4e70-aa72-bb5109d4674a",
      "name": "Passed check 3?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        2560,
        320
      ],
      "notes": "True = passed. False = did not pass.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "4197f99c-ca72-4cf5-a172-1d565e59436f",
              "name": "flag",
              "value": "=Every quoted place is really in the draft: did not pass. {{ $(\"Check 3: Every quoted place is really in the draft\").first().json.reason }}",
              "type": "string"
            }
          ]
        },
        "options": {}
      },
      "id": "9b36d3f8-989f-4d49-a00d-b08ae45d0aa3",
      "name": "Flag check 3",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        2690,
        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: The must fixes are truly must\nLook at every fix rated must. Would sending the draft below without that fix really risk misleading the reader, losing the ask, or causing harm? Answer FAIL if any must fix is only a style preference.\n\nThe draft:\n{{ $(\"Inputs\").first().json[\"draft\"] }}\n\nThe work to check (the answer from Step 3 (Ranked fix list)):\n{{ $(\"3. Ranked fix list\").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": "dd865189-4ee9-4054-ab3f-f0231e84f40e",
      "name": "Check 4: The must fixes are truly must",
      "type": "@n8n/n8n-nodes-langchain.chainLlm",
      "typeVersion": 1.7,
      "position": [
        2820,
        320
      ],
      "notes": "Answers PASS or FAIL. Look at every fix rated must. Would sending the draft below without that fix really risk misleading the reader, losing the ask, or causing harm? Answer FAIL if any must fix is only a style preference.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "57d8d7a1-7aeb-4e14-a28b-7ed724d43c8a",
              "leftValue": "={{ String($json.text ?? '').trim().toUpperCase().startsWith('PASS') }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "4d6c14d4-a293-492b-aa70-7cba89d76f61",
      "name": "Passed check 4?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        3080,
        320
      ],
      "notes": "True = passed. False = did not pass.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "66b0eb86-1477-43f7-a7ad-9ecc2a05449d",
              "leftValue": "={{ $runIndex }}",
              "rightValue": 0,
              "operator": {
                "type": "number",
                "operation": "equals"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "fa041f16-3b53-45e7-a81d-101ce5f97aed",
      "name": "Try again? (check 4)",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        3080,
        520
      ],
      "notes": "True = first failure: send back to \"3. Ranked fix list\" once. False = failed twice: flag it.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "4297fb2f-c972-4b62-a272-1ee95d5941dc",
              "name": "flag",
              "value": "=The must fixes are truly must: did not pass. {{ $(\"Check 4: The must fixes are truly must\").first().json.text }}",
              "type": "string"
            }
          ]
        },
        "options": {}
      },
      "id": "a236defd-999f-4edc-ab0d-a8abe95d1282",
      "name": "Flag check 4",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        3210,
        700
      ],
      "notes": "Notes the problem and carries on. The note shows in Result.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "content": "**Check 5: check by hand**\nRead the must fixes. Do you agree with each one, and do you know how to make it?\n\nIf not: flag it and carry on. This check is also listed in Result.",
        "height": 200,
        "width": 240,
        "color": 3
      },
      "id": "cfb554f3-83d8-4e8c-af7a-a0d524a7f8e6",
      "name": "Check 5 (by hand)",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        3060,
        70
      ]
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "81f3d229-e91e-47aa-a77d-5e8be71376f4",
              "name": "result",
              "value": "={{ $(\"3. Ranked fix list\").first().json.text }}",
              "type": "string"
            },
            {
              "id": "625a3a96-84d8-40d3-aa44-32a02b5954bd",
              "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": "c2328d04-d19d-4189-a629-e2fe4d8921f3",
              "name": "check_by_hand",
              "value": "- Read the must fixes. Do you agree with each one, and do you know how to make it?",
              "type": "string"
            }
          ]
        },
        "options": {}
      },
      "id": "558b02aa-2bf8-42cf-a5be-3c9cd7432359",
      "name": "Result",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        3340,
        320
      ],
      "notes": "The final answer, anything flagged, and the checks left for you.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "options": {}
      },
      "id": "f5e2bc64-94f7-46b5-af7d-dd6e0985491f",
      "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. Writing pass",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "1. Writing pass": {
      "main": [
        [
          {
            "node": "2. Substance pass",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "2. Substance pass": {
      "main": [
        [
          {
            "node": "Check 1: Each claim is marked sourced or unsourced",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check 1: Each claim is marked sourced or unsourced": {
      "main": [
        [
          {
            "node": "Passed check 1?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Passed check 1?": {
      "main": [
        [
          {
            "node": "3. Ranked fix list",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Try again? (check 1)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Try again? (check 1)": {
      "main": [
        [
          {
            "node": "2. Substance pass",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Flag check 1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Flag check 1": {
      "main": [
        [
          {
            "node": "3. Ranked fix list",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "3. Ranked fix list": {
      "main": [
        [
          {
            "node": "Check 2: Each fix is rated must, should or nice",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check 2: Each fix is rated must, should or nice": {
      "main": [
        [
          {
            "node": "Passed check 2?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Passed check 2?": {
      "main": [
        [
          {
            "node": "Check 3: Every quoted place is really in the draft",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Try again? (check 2)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Try again? (check 2)": {
      "main": [
        [
          {
            "node": "3. Ranked fix list",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Flag check 2",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Flag check 2": {
      "main": [
        [
          {
            "node": "Check 3: Every quoted place is really in the draft",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check 3: Every quoted place is really in the draft": {
      "main": [
        [
          {
            "node": "Passed check 3?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Passed check 3?": {
      "main": [
        [
          {
            "node": "Check 4: The must fixes are truly must",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Flag check 3",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Flag check 3": {
      "main": [
        [
          {
            "node": "Check 4: The must fixes are truly must",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check 4: The must fixes are truly must": {
      "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": "3. Ranked fix list",
            "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. Writing pass",
            "type": "ai_languageModel",
            "index": 0
          },
          {
            "node": "2. Substance pass",
            "type": "ai_languageModel",
            "index": 0
          },
          {
            "node": "3. Ranked fix list",
            "type": "ai_languageModel",
            "index": 0
          },
          {
            "node": "Check 4: The must fixes are truly must",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    }
  },
  "pinData": {},
  "settings": {
    "executionOrder": "v1"
  },
  "meta": {
    "template": "review-draft-before-sending"
  }
}
