{
  "name": "Score work against a rubric",
  "nodes": [
    {
      "parameters": {
        "content": "## Score work against a rubric\nScore a piece of work against a written rubric, one criterion at a time, quote the evidence for each mark, and say what would reach the top level, using only the rubric you give.\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 quote in the Met looks like (quote) column appears word for word in the rubric. If it fails: try once more, then flag.\n- Check 2 (a plain rule, run automatically with no AI): Every quote in the Passage in the work (quote) column appears word for word in the work to mark. If it fails: try once more, then flag.\n- Check 3 (a plain rule, run automatically with no AI): Every row of the table has one of \"met\", \"partly met\" or \"not met\" in the Mark column. If it fails: try once more, then flag.\n- Check 4 (a plain rule, run automatically with no AI): Every row of the table has something written in \"Criterion (quote)\", \"Mark\", \"Evidence\" and \"To reach met\" (a blank or a dash does not count). If it fails: try once more, then flag.\n- Check 5 (a plain rule, run automatically with no AI): Every quote in the Criterion (quote) column appears word for word in the rubric. If it fails: try once more, then flag.\n- Check 6 (a second AI prompt answers pass or fail): Count the marks in the table below. Do the numbers on the Result line of the report match those counts, and does the number of rows under \"To improve\" equal the number of criteria not marked met? Answer FAIL and say which count is wrong. If it fails: try once more, then flag.\n- Check 7 (a second AI prompt answers pass or fail): Compare each row of the report with the rubric and the work below. Does each mark match the rubric's wording for that level given the evidence, and does each \"To reach met\" use only the rubric's wording and facts that are already in the work? Answer FAIL and quote any row that gives credit the rubric does not allow, or suggests content that the rubric and the work do not contain. If it fails: try once more, then flag.\n- Check 8 (you check it by hand): Read the evidence beside each mark. For any criterion that asks you to judge, such as whether something suits the audience, decide whether you agree. The marks are a first read for you to confirm, not a final grade. If it fails: flag it and carry on.\n\nFrom Logic Lab (logiclabhq.com). Free to use and adapt, with credit.",
        "height": 820,
        "width": 760,
        "color": 5
      },
      "id": "a448f2ea-e9d4-4c61-a7eb-caf007bf7d2f",
      "name": "About this workflow",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -40,
        -780
      ]
    },
    {
      "parameters": {},
      "id": "154a3b0e-7b7f-4869-afaa-d42c090f95cf",
      "name": "Start",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        0,
        320
      ]
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "1cbcea66-71c7-4e43-a2a3-4048e580133d",
              "name": "rubric",
              "value": "Sample: Rubric for workshop proposals. Each criterion is marked met, partly met or not met.\n1. Clear aim. Met: the proposal states who the workshop is for and what they will be able to do afterward. Partly met: it names the audience or the outcome but not both. Not met: it names neither.\n2. Realistic plan. Met: it gives the number of sessions, the length of each and who will lead them. Partly met: it gives two of the three. Not met: it gives one or none.\n3. Fits the audience. Met: the day, time and place suit the audience the proposal names. Partly met: the topic suits them but the day, time or place does not, or the reverse. Not met: the proposal names no audience, or nothing suits them.\n4. Cost. Met: it gives a total cost and shows what the money buys. Partly met: it gives a total with no breakdown. Not met: it gives no cost.\n5. Way to tell if it worked. Met: it names something that will be counted or asked at the end. Partly met: it says it will gather feedback without saying how. Not met: it says nothing about this.",
              "type": "string"
            },
            {
              "id": "292c03e2-6476-4a47-abec-2c9cb4e363f9",
              "name": "work",
              "value": "Sample: Proposal: Spreadsheet Skills Workshop. Many small shop owners in our town cannot say which products make money. Most open at 9 am and close at 6 pm. We will help them get confident enough with spreadsheets to find out. The workshop runs for four sessions, two hours each, on weekday mornings at the public library. Our volunteer accountant, Ravi Mehta, will lead every session, and owners will bring their own receipts. We will charge nothing, and we ask for 1,200 dollars.",
              "type": "string"
            }
          ]
        },
        "options": {}
      },
      "id": "982a6d03-8901-4e1e-a60c-d685e777c128",
      "name": "Inputs",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        220,
        320
      ],
      "notes": "Paste your own material here: rubric = The rubric; work = The work to mark.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "promptType": "define",
        "hasOutputParser": false,
        "text": "=You are a careful reader who turns a rubric into items that can be marked one by one.\n\nYour task is to list every criterion in the rubric and what it says met looks like.\n\nThe rubric:\n{{ $(\"Inputs\").first().json[\"rubric\"] }}\n\nRules:\n- One row for each criterion, in the order the rubric gives them. Do not merge two criteria and do not add one the rubric does not have.\n- Quote the criterion's name from the rubric word for word. In the last column, quote the rubric's own words for what met looks like, as short as you can while keeping its numbers and conditions. Do not reword either one.\n- Use only the rubric. Do not add your own idea of what good work looks like.\n- Do not read the work yet.\n\nReturn one markdown table and nothing else:\n| # | Criterion (quote) | Met looks like (quote) |{{ $(\"Check 1: Every description of met is quoted word for word from the rubric\").isExecuted ? \"\\n\\nYour last answer did not pass this check: Every description of met is quoted word for word from the rubric. What was wrong: \" + $(\"Check 1: Every description of met is quoted word for word from the rubric\").first().json.reason + \"\\nFix that and give your full answer again.\" : \"\" }}"
      },
      "id": "1cc53d45-1293-44ec-aa98-ec636c5f4202",
      "name": "1. List each criterion and what met looks like",
      "type": "@n8n/n8n-nodes-langchain.chainLlm",
      "typeVersion": 1.7,
      "position": [
        480,
        320
      ],
      "notes": "Split the rubric into separate criteria, quoted word for word, with the rubric's own words for the top level. Returns: A numbered table of the criteria, each with the rubric's own words for met.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "jsCode": "// Every description of met is quoted word for word from the rubric\n// Rule: Every quote in the Met looks like (quote) column appears word for word in the rubric.\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($(\"1. List each criterion and what met looks like\").first().json.text ?? '');\nconst sources = {\n  \"rubric\": String($(\"Inputs\").first().json[\"rubric\"] ?? ''),\n};\nconst result = runRule(\"column_quotes_source\", {\"column\":\"Met looks like (quote)\",\"input\":\"rubric\"}, work, sources);\nreturn [{ json: result }];"
      },
      "id": "0c53f75e-0d07-41e1-ad91-0f707236fa63",
      "name": "Check 1: Every description of met is quoted word for word from the rubric",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        740,
        320
      ],
      "notes": "Every quote in the Met looks like (quote) column appears word for word in the rubric.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "8617dd75-8b92-4b60-a685-9ba7c8d4ee22",
              "leftValue": "={{ $json.pass === true }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "c5e8f396-fedc-4f21-a009-1b6c9529ba67",
      "name": "Passed check 1?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1000,
        320
      ],
      "notes": "True = passed. False = did not pass.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "e92f5652-489d-4e6b-ab89-4794ef85b8bd",
              "leftValue": "={{ $runIndex }}",
              "rightValue": 0,
              "operator": {
                "type": "number",
                "operation": "equals"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "581456ae-b016-4ecf-a967-f6c42cb71ddd",
      "name": "Try again? (check 1)",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1000,
        520
      ],
      "notes": "True = first failure: send back to \"1. List each criterion and what met looks like\" once. False = failed twice: flag it.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "1aad7767-7e8d-4d6a-a8f9-27c51e29b458",
              "name": "flag",
              "value": "=Every description of met is quoted word for word from the rubric: did not pass. {{ $(\"Check 1: Every description of met is quoted word for word from the rubric\").first().json.reason }}",
              "type": "string"
            }
          ]
        },
        "options": {}
      },
      "id": "e8da0b2b-187f-4176-ada6-5b79e5ce857c",
      "name": "Flag check 1",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        1130,
        700
      ],
      "notes": "Notes the problem and carries on. The note shows in Result.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "promptType": "define",
        "hasOutputParser": false,
        "text": "=You are a research assistant who finds the exact words in a piece of work that bear on a criterion.\n\nYour task is to find, for each criterion below, the passage in the work that bears on it.\n\nThe criteria:\n{{ $(\"1. List each criterion and what met looks like\").first().json.text }}\n\nThe work:\n{{ $(\"Inputs\").first().json[\"work\"] }}\n\nRules:\n- Quote the passage from the work word for word, as short as you can while keeping its numbers, names and dates. If the evidence is in two places, quote both pieces and put ... between them. Do not reword anything.\n- Quote a passage even when it falls short of the criterion. If nothing in the work bears on the criterion, write exactly None found.\n- Use only the work above. Do not use anything you know from outside it, and do not guess what the writer meant.\n- Do not mark anything yet. Do not give credit for length or style.\n\nReturn one markdown table and nothing else:\n| # | Criterion (quote) | Passage in the work (quote) |{{ $(\"Check 2: Every passage is quoted word for word from the work\").isExecuted ? \"\\n\\nYour last answer did not pass this check: Every passage is quoted word for word from the work. What was wrong: \" + $(\"Check 2: Every passage is quoted word for word from the work\").first().json.reason + \"\\nFix that and give your full answer again.\" : \"\" }}"
      },
      "id": "c6ebc70d-a68e-465c-a016-3f27a9a82886",
      "name": "2. Find the passage for each criterion",
      "type": "@n8n/n8n-nodes-langchain.chainLlm",
      "typeVersion": 1.7,
      "position": [
        1260,
        320
      ],
      "notes": "For each criterion, quote the words in the work that bear on it, or say that none do. Returns: A table that puts a quoted passage from the work beside each criterion, or None found.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "jsCode": "// Every passage is quoted word for word from the work\n// Rule: Every quote in the Passage in the work (quote) column appears word for word in the work to mark.\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. Find the passage for each criterion\").first().json.text ?? '');\nconst sources = {\n  \"work\": String($(\"Inputs\").first().json[\"work\"] ?? ''),\n};\nconst result = runRule(\"column_quotes_source\", {\"column\":\"Passage in the work (quote)\",\"input\":\"work\"}, work, sources);\nreturn [{ json: result }];"
      },
      "id": "f9504964-af79-4751-ab1e-cfae11a7a293",
      "name": "Check 2: Every passage is quoted word for word from the work",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1520,
        320
      ],
      "notes": "Every quote in the Passage in the work (quote) column appears word for word in the work to mark.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "8517dbe2-8c92-4cf3-a585-9a14c9d4efb5",
              "leftValue": "={{ $json.pass === true }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "45e16e51-fee3-4ae6-a00b-5a0395277bd0",
      "name": "Passed check 2?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1780,
        320
      ],
      "notes": "True = passed. False = did not pass.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "ea2f57e5-479d-4cd8-ac89-4927ee85b72a",
              "leftValue": "={{ $runIndex }}",
              "rightValue": 0,
              "operator": {
                "type": "number",
                "operation": "equals"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "c01738fd-301e-4414-a960-717f44b5050e",
      "name": "Try again? (check 2)",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1780,
        520
      ],
      "notes": "True = first failure: send back to \"2. Find the passage for each criterion\" once. False = failed twice: flag it.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "19ad75d4-7f8d-4efd-a7f9-26321f29b5eb",
              "name": "flag",
              "value": "=Every passage is quoted word for word from the work: did not pass. {{ $(\"Check 2: Every passage is quoted word for word from the work\").first().json.reason }}",
              "type": "string"
            }
          ]
        },
        "options": {}
      },
      "id": "e9da0cbe-177f-4fe3-aaa6-56c0e8ce8a35",
      "name": "Flag check 2",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        1910,
        700
      ],
      "notes": "Notes the problem and carries on. The note shows in Result.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "promptType": "define",
        "hasOutputParser": false,
        "text": "=You are a strict marker who applies a rubric exactly as it is written.\n\nYour task is to mark each criterion below as met, partly met or not met.\n\nThe rubric:\n{{ $(\"Inputs\").first().json[\"rubric\"] }}\n\nThe criteria and the passages found for them:\n{{ $(\"2. Find the passage for each criterion\").first().json.text }}\n\nRules:\n- Use exactly these marks: met, partly met, not met.\n- Mark each criterion by the rubric's own wording for the levels. Choose the level whose wording the passage matches. If the rubric has no wording for a level, use the met wording, and give partly met only when part of it is shown.\n- In \"Why\", write one sentence that names the words in the passage that decided it and the rubric wording they were compared with.\n- Judge only from the passages above and the rubric. Do not give credit for length, polish or effort, and do not take credit away for style.\n- Do not add the marks up and do not turn them into numbers.\n\nReturn one markdown table and nothing else:\n| # | Criterion (quote) | Mark | Why |\nThe Mark column must say exactly met, partly met or not met.{{ $(\"Check 3: Each mark is one of the three allowed words\").isExecuted ? \"\\n\\nYour last answer did not pass this check: Each mark is one of the three allowed words. What was wrong: \" + $(\"Check 3: Each mark is one of the three allowed words\").first().json.reason + \"\\nFix that and give your full answer again.\" : \"\" }}"
      },
      "id": "a9eb347a-ab7a-4ddf-a208-ef8c55c85ec9",
      "name": "3. Mark each criterion",
      "type": "@n8n/n8n-nodes-langchain.chainLlm",
      "typeVersion": 1.7,
      "position": [
        2040,
        320
      ],
      "notes": "Compare each passage with the rubric's wording and give one of three marks, with the reason in one sentence. Returns: A table that gives each criterion one of three marks and the reason.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "jsCode": "// Each mark is one of the three allowed words\n// Rule: Every row of the table has one of \"met\", \"partly met\" or \"not met\" in the Mark 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. Mark each criterion\").first().json.text ?? '');\nconst sources = {\n\n};\nconst result = runRule(\"column_values_in\", {\"column\":\"Mark\",\"allowed\":[\"met\",\"partly met\",\"not met\"]}, work, sources);\nreturn [{ json: result }];"
      },
      "id": "b7e85aa2-cfbe-447b-a58b-fc34196e7575",
      "name": "Check 3: Each mark is one of the three allowed words",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2300,
        320
      ],
      "notes": "Every row of the table has one of \"met\", \"partly met\" or \"not met\" in the Mark column.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "8417da4f-8d92-4e86-a885-9ecdc6d4eafc",
              "leftValue": "={{ $json.pass === true }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "c5e47668-fee1-4c4f-a00e-621a152473b9",
      "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": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "e72f532c-4a9d-4191-ad89-4abaed85b597",
              "leftValue": "={{ $runIndex }}",
              "rightValue": 0,
              "operator": {
                "type": "number",
                "operation": "equals"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "d4199710-0c1b-4cd1-ad62-e8c220b28dcb",
      "name": "Try again? (check 3)",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        2560,
        520
      ],
      "notes": "True = first failure: send back to \"3. Mark each criterion\" once. False = failed twice: flag it.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "1cad7a8d-7c8d-4a44-a6f9-249f2029b77e",
              "name": "flag",
              "value": "=Each mark is one of the three allowed words: did not pass. {{ $(\"Check 3: Each mark is one of the three allowed words\").first().json.reason }}",
              "type": "string"
            }
          ]
        },
        "options": {}
      },
      "id": "eada0e51-167f-4e50-aba6-5853e7ce88a2",
      "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 an editor who turns a marking sheet into feedback the writer can act on.\n\nYour task is to write the marking report from the marks and passages below.\n\nThe rubric:\n{{ $(\"Inputs\").first().json[\"rubric\"] }}\n\nThe marks:\n{{ $(\"3. Mark each criterion\").first().json.text }}\n\nThe passages:\n{{ $(\"2. Find the passage for each criterion\").first().json.text }}\n\nRules:\n- Start with one line that begins \"Result:\", gives the number of criteria marked and says how many were met, partly met and not met.\n- Under \"To improve\", write one row for each criterion that is not met, in the same order as the marks. If every criterion is met, write one row with None found in every cell.\n- In \"Evidence\", give the passage from the work, shortened. If the passages table says None found, write: No passage in the work.\n- In \"To reach met\", say in one sentence what the work would need to show, using only the rubric's wording for met and the facts already in the work. Do not write new content for the writer, and do not add a fact, number or name that is not in the work.\n- Under \"Met\", list each criterion marked met as the quoted criterion, one line each, with nothing else.\n\nReturn markdown in this form and nothing else:\nResult: (the counts)\n## To improve\n| # | Criterion (quote) | Mark | Evidence | To reach met |\n## Met\n(one quoted criterion per line){{ $(\"Check 4: Every row to improve is filled in\").isExecuted ? \"\\n\\nYour last answer did not pass this check: Every row to improve is filled in. What was wrong: \" + $(\"Check 4: Every row to improve is filled in\").first().json.reason + \"\\nFix that and give your full answer again.\" : \"\" }}{{ $(\"Check 5: Every criterion in the report is in the rubric word for word\").isExecuted ? \"\\n\\nYour last answer did not pass this check: Every criterion in the report is in the rubric word for word. What was wrong: \" + $(\"Check 5: Every criterion in the report is in the rubric word for word\").first().json.reason + \"\\nFix that and give your full answer again.\" : \"\" }}{{ $(\"Check 6: The counts add up to the marks\").isExecuted ? \"\\n\\nYour last answer did not pass this check: The counts add up to the marks. What was wrong: \" + $(\"Check 6: The counts add up to the marks\").first().json.text + \"\\nFix that and give your full answer again.\" : \"\" }}{{ $(\"Check 7: Each mark and each fix follows the rubric's own words\").isExecuted ? \"\\n\\nYour last answer did not pass this check: Each mark and each fix follows the rubric's own words. What was wrong: \" + $(\"Check 7: Each mark and each fix follows the rubric's own words\").first().json.text + \"\\nFix that and give your full answer again.\" : \"\" }}"
      },
      "id": "8aa4664f-3735-4094-a400-33cd20b01302",
      "name": "4. Write the marking report",
      "type": "@n8n/n8n-nodes-langchain.chainLlm",
      "typeVersion": 1.7,
      "position": [
        2820,
        320
      ],
      "notes": "Turn the marks into a count, a list of what to improve with wording taken from the rubric, and a list of what is met. Returns: A report with a count line, a table of what to improve and a list of the criteria that are met.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "jsCode": "// Every row to improve is filled in\n// Rule: Every row of the table has something written in \"Criterion (quote)\", \"Mark\", \"Evidence\" and \"To reach met\" (a blank or a dash does not count).\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. Write the marking report\").first().json.text ?? '');\nconst sources = {\n\n};\nconst result = runRule(\"table_cells_filled\", [\"Criterion (quote)\",\"Mark\",\"Evidence\",\"To reach met\"], work, sources);\nreturn [{ json: result }];"
      },
      "id": "51e2e7e3-873b-4eba-a42d-6b1d6c67a954",
      "name": "Check 4: Every row to improve is filled in",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3080,
        320
      ],
      "notes": "Every row of the table has something written in \"Criterion (quote)\", \"Mark\", \"Evidence\" and \"To reach met\" (a blank or a dash does not count).",
      "notesInFlow": true
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "8317d8bc-8e92-4019-a785-9d3ac7d4ec8f",
              "leftValue": "={{ $json.pass === true }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "45dcf123-7ed5-49dc-a010-a0b115223522",
      "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": "e82f54bf-499d-4ffe-ae89-4c4dec85b404",
              "leftValue": "={{ $runIndex }}",
              "rightValue": 0,
              "operator": {
                "type": "number",
                "operation": "equals"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "bc1bafdf-8c0f-4a5e-ad5b-311d28c3e384",
      "name": "Try again? (check 4)",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        3340,
        520
      ],
      "notes": "True = first failure: send back to \"4. Write the marking report\" once. False = failed twice: flag it.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "1bad78fa-7d8d-4bd7-a5f9-230c2129b911",
              "name": "flag",
              "value": "=Every row to improve is filled in: did not pass. {{ $(\"Check 4: Every row to improve is filled in\").first().json.reason }}",
              "type": "string"
            }
          ]
        },
        "options": {}
      },
      "id": "ebda0fe4-1d7f-4955-a0a6-6032e2ce80c3",
      "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": {
        "jsCode": "// Every criterion in the report is in the rubric word for word\n// Rule: Every quote in the Criterion (quote) column appears word for word in the rubric.\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. Write the marking report\").first().json.text ?? '');\nconst sources = {\n  \"rubric\": String($(\"Inputs\").first().json[\"rubric\"] ?? ''),\n};\nconst result = runRule(\"column_quotes_source\", {\"column\":\"Criterion (quote)\",\"input\":\"rubric\"}, work, sources);\nreturn [{ json: result }];"
      },
      "id": "be0de2b5-6ba0-497a-a80a-36a30629c1d8",
      "name": "Check 5: Every criterion in the report is in the rubric word for word",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3600,
        320
      ],
      "notes": "Every quote in the Criterion (quote) column appears word for word in the rubric.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "8217d729-8f92-41ac-a285-955bc4d4e7d6",
              "leftValue": "={{ $json.pass === true }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "45df2fba-fed2-41c5-a012-df48151ff68b",
      "name": "Passed check 5?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        3860,
        320
      ],
      "notes": "True = passed. False = did not pass.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "e52f5006-4c9d-44b7-a789-4148eb85b271",
              "leftValue": "={{ $runIndex }}",
              "rightValue": 0,
              "operator": {
                "type": "number",
                "operation": "equals"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "e01e2722-280c-4e5b-a15d-8f30c4c10781",
      "name": "Try again? (check 5)",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        3860,
        520
      ],
      "notes": "True = first failure: send back to \"4. Write the marking report\" once. False = failed twice: flag it.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "16ad711b-7a8d-471e-a4f9-21792229baa4",
              "name": "flag",
              "value": "=Every criterion in the report is in the rubric word for word: did not pass. {{ $(\"Check 5: Every criterion in the report is in the rubric word for word\").first().json.reason }}",
              "type": "string"
            }
          ]
        },
        "options": {}
      },
      "id": "ecda1177-1c7f-47c2-a1a6-61c5e1ce7f30",
      "name": "Flag check 5",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        3990,
        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 counts add up to the marks\nCount the marks in the table below. Do the numbers on the Result line of the report match those counts, and does the number of rows under \"To improve\" equal the number of criteria not marked met? Answer FAIL and say which count is wrong.\n\nThe marks:\n{{ $(\"3. Mark each criterion\").first().json.text }}\n\nThe work to check (the answer from Step 4 (Write the marking report)):\n{{ $(\"4. Write the marking report\").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": "bac69caf-8d43-49f0-a68d-9c29b7ba0362",
      "name": "Check 6: The counts add up to the marks",
      "type": "@n8n/n8n-nodes-langchain.chainLlm",
      "typeVersion": 1.7,
      "position": [
        4120,
        320
      ],
      "notes": "Answers PASS or FAIL. Count the marks in the table below. Do the numbers on the Result line of the report match those counts, and does the number of rows under \"To improve\" equal the number of criteria not marked met? Answer FAIL and say which count is wrong.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "8117d596-9092-433f-a185-93c8c5d4e969",
              "leftValue": "={{ String($json.text ?? '').trim().toUpperCase().startsWith('PASS') }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "c5d7aa75-7ed9-470a-a015-1ddf151db7f4",
      "name": "Passed check 6?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        4380,
        320
      ],
      "notes": "True = passed. False = did not pass.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "e62f5199-4b9d-4324-a889-42dbea85b0de",
              "leftValue": "={{ $runIndex }}",
              "rightValue": 0,
              "operator": {
                "type": "number",
                "operation": "equals"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "582122a1-8814-4140-a156-a10bccbed582",
      "name": "Try again? (check 6)",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        4380,
        520
      ],
      "notes": "True = first failure: send back to \"4. Write the marking report\" once. False = failed twice: flag it.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "15ad6f88-7b8d-48b1-a3f9-1fe62329bc37",
              "name": "flag",
              "value": "=The counts add up to the marks: did not pass. {{ $(\"Check 6: The counts add up to the marks\").first().json.text }}",
              "type": "string"
            }
          ]
        },
        "options": {}
      },
      "id": "edda130a-1b7f-462f-aea6-5d0ce4ce83e9",
      "name": "Flag check 6",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        4510,
        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: Each mark and each fix follows the rubric's own words\nCompare each row of the report with the rubric and the work below. Does each mark match the rubric's wording for that level given the evidence, and does each \"To reach met\" use only the rubric's wording and facts that are already in the work? Answer FAIL and quote any row that gives credit the rubric does not allow, or suggests content that the rubric and the work do not contain.\n\nThe rubric:\n{{ $(\"Inputs\").first().json[\"rubric\"] }}\n\nThe work:\n{{ $(\"Inputs\").first().json[\"work\"] }}\n\nThe work to check (the answer from Step 4 (Write the marking report)):\n{{ $(\"4. Write the marking report\").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": "b3a348e7-264c-4f22-a97d-66a5cede2200",
      "name": "Check 7: Each mark and each fix follows the rubric's own words",
      "type": "@n8n/n8n-nodes-langchain.chainLlm",
      "typeVersion": 1.7,
      "position": [
        4640,
        320
      ],
      "notes": "Answers PASS or FAIL. Compare each row of the report with the rubric and the work below. Does each mark match the rubric's wording for that level given the evidence, and does each \"To reach met\" use only the rubric's wording and facts that are already in the work? Answer FAIL and quote any row that gives credit the rubric does not allow, or suggests content that the rubric and the work do not contain.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "8017d403-9192-44d2-a485-9881c2d4e4b0",
              "leftValue": "={{ String($json.text ?? '').trim().toUpperCase().startsWith('PASS') }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "c5d9e90c-7ed7-4873-a018-25f6951aafdd",
      "name": "Passed check 7?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        4900,
        320
      ],
      "notes": "True = passed. False = did not pass.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "e32f4ce0-4e9d-47dd-a989-446ee985af4b",
              "leftValue": "={{ $runIndex }}",
              "rightValue": 0,
              "operator": {
                "type": "number",
                "operation": "equals"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "3c233524-b412-47ed-a559-184ea8bc5e3f",
      "name": "Try again? (check 7)",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        4900,
        520
      ],
      "notes": "True = first failure: send back to \"4. Write the marking report\" once. False = failed twice: flag it.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "18ad7441-788d-43f8-a2f9-1e532429bdca",
              "name": "flag",
              "value": "=Each mark and each fix follows the rubric's own words: did not pass. {{ $(\"Check 7: Each mark and each fix follows the rubric's own words\").first().json.text }}",
              "type": "string"
            }
          ]
        },
        "options": {}
      },
      "id": "eeda149d-1a7f-449c-afa6-5e9fe3ce8256",
      "name": "Flag check 7",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        5030,
        700
      ],
      "notes": "Notes the problem and carries on. The note shows in Result.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "content": "**Check 8: check by hand**\nRead the evidence beside each mark. For any criterion that asks you to judge, such as whether something suits the audience, decide whether you agree. The marks are a first read for you to confirm, not a final grade.\n\nIf not: flag it and carry on. This check is also listed in Result.",
        "height": 200,
        "width": 240,
        "color": 3
      },
      "id": "f32b81fb-6af3-4a84-a1a7-7a7d87a5d5b6",
      "name": "Check 8 (by hand)",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        4880,
        70
      ]
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "caa87de2-3375-4df1-a7be-4c0420ad7f53",
              "name": "result",
              "value": "={{ $(\"4. Write the marking report\").first().json.text }}",
              "type": "string"
            },
            {
              "id": "3bc5d0bb-60a7-40ae-a0f7-80ad754a1c18",
              "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 : \"\", $(\"Flag check 5\").isExecuted ? $(\"Flag check 5\").first().json.flag : \"\", $(\"Flag check 6\").isExecuted ? $(\"Flag check 6\").first().json.flag : \"\", $(\"Flag check 7\").isExecuted ? $(\"Flag check 7\").first().json.flag : \"\"].filter(x => x).join(\"\\n\") || \"None\" }}",
              "type": "string"
            },
            {
              "id": "283c7311-6578-447c-a431-aca313128716",
              "name": "check_by_hand",
              "value": "- Read the evidence beside each mark. For any criterion that asks you to judge, such as whether something suits the audience, decide whether you agree. The marks are a first read for you to confirm, not a final grade.",
              "type": "string"
            }
          ]
        },
        "options": {}
      },
      "id": "74233517-8c0e-4f22-a6ee-72693ec4d8f4",
      "name": "Result",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        5160,
        320
      ],
      "notes": "The final answer, anything flagged, and the checks left for you.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "options": {}
      },
      "id": "a215e24d-72c9-48cc-ae33-e7cf17b8afd6",
      "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. List each criterion and what met looks like",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "1. List each criterion and what met looks like": {
      "main": [
        [
          {
            "node": "Check 1: Every description of met is quoted word for word from the rubric",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check 1: Every description of met is quoted word for word from the rubric": {
      "main": [
        [
          {
            "node": "Passed check 1?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Passed check 1?": {
      "main": [
        [
          {
            "node": "2. Find the passage for each criterion",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Try again? (check 1)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Try again? (check 1)": {
      "main": [
        [
          {
            "node": "1. List each criterion and what met looks like",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Flag check 1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Flag check 1": {
      "main": [
        [
          {
            "node": "2. Find the passage for each criterion",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "2. Find the passage for each criterion": {
      "main": [
        [
          {
            "node": "Check 2: Every passage is quoted word for word from the work",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check 2: Every passage is quoted word for word from the work": {
      "main": [
        [
          {
            "node": "Passed check 2?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Passed check 2?": {
      "main": [
        [
          {
            "node": "3. Mark each criterion",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Try again? (check 2)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Try again? (check 2)": {
      "main": [
        [
          {
            "node": "2. Find the passage for each criterion",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Flag check 2",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Flag check 2": {
      "main": [
        [
          {
            "node": "3. Mark each criterion",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "3. Mark each criterion": {
      "main": [
        [
          {
            "node": "Check 3: Each mark is one of the three allowed words",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check 3: Each mark is one of the three allowed words": {
      "main": [
        [
          {
            "node": "Passed check 3?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Passed check 3?": {
      "main": [
        [
          {
            "node": "4. Write the marking report",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Try again? (check 3)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Try again? (check 3)": {
      "main": [
        [
          {
            "node": "3. Mark each criterion",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Flag check 3",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Flag check 3": {
      "main": [
        [
          {
            "node": "4. Write the marking report",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "4. Write the marking report": {
      "main": [
        [
          {
            "node": "Check 4: Every row to improve is filled in",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check 4: Every row to improve is filled in": {
      "main": [
        [
          {
            "node": "Passed check 4?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Passed check 4?": {
      "main": [
        [
          {
            "node": "Check 5: Every criterion in the report is in the rubric word for word",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Try again? (check 4)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Try again? (check 4)": {
      "main": [
        [
          {
            "node": "4. Write the marking report",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Flag check 4",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Flag check 4": {
      "main": [
        [
          {
            "node": "Check 5: Every criterion in the report is in the rubric word for word",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check 5: Every criterion in the report is in the rubric word for word": {
      "main": [
        [
          {
            "node": "Passed check 5?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Passed check 5?": {
      "main": [
        [
          {
            "node": "Check 6: The counts add up to the marks",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Try again? (check 5)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Try again? (check 5)": {
      "main": [
        [
          {
            "node": "4. Write the marking report",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Flag check 5",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Flag check 5": {
      "main": [
        [
          {
            "node": "Check 6: The counts add up to the marks",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check 6: The counts add up to the marks": {
      "main": [
        [
          {
            "node": "Passed check 6?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Passed check 6?": {
      "main": [
        [
          {
            "node": "Check 7: Each mark and each fix follows the rubric's own words",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Try again? (check 6)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Try again? (check 6)": {
      "main": [
        [
          {
            "node": "4. Write the marking report",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Flag check 6",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Flag check 6": {
      "main": [
        [
          {
            "node": "Check 7: Each mark and each fix follows the rubric's own words",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check 7: Each mark and each fix follows the rubric's own words": {
      "main": [
        [
          {
            "node": "Passed check 7?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Passed check 7?": {
      "main": [
        [
          {
            "node": "Result",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Try again? (check 7)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Try again? (check 7)": {
      "main": [
        [
          {
            "node": "4. Write the marking report",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Flag check 7",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Flag check 7": {
      "main": [
        [
          {
            "node": "Result",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Chat model": {
      "ai_languageModel": [
        [
          {
            "node": "1. List each criterion and what met looks like",
            "type": "ai_languageModel",
            "index": 0
          },
          {
            "node": "2. Find the passage for each criterion",
            "type": "ai_languageModel",
            "index": 0
          },
          {
            "node": "3. Mark each criterion",
            "type": "ai_languageModel",
            "index": 0
          },
          {
            "node": "4. Write the marking report",
            "type": "ai_languageModel",
            "index": 0
          },
          {
            "node": "Check 6: The counts add up to the marks",
            "type": "ai_languageModel",
            "index": 0
          },
          {
            "node": "Check 7: Each mark and each fix follows the rubric's own words",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    }
  },
  "pinData": {},
  "settings": {
    "executionOrder": "v1"
  },
  "meta": {
    "template": "score-work-against-a-rubric"
  }
}
