{
  "id": "",
  "meta": {
    "templateCredsSetupCompleted": false
  },
  "name": "Telegram Expense Tracker \u2014 Query Bot (Template)",
  "tags": [],
  "nodes": [
    {
      "id": "58535881-40c5-4d75-82c6-f0faa9443e1a",
      "name": "MSG | Telegram Inbound",
      "type": "n8n-nodes-base.telegramTrigger",
      "position": [
        2144,
        1088
      ],
      "parameters": {
        "updates": [
          "message",
          "callback_query"
        ],
        "additionalFields": {}
      },
      "credentials": {
        "telegramApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1.1
    },
    {
      "id": "69f61c14-bd15-4c5a-b0b8-ada463bff839",
      "name": "LLM | Parse Intent",
      "type": "@n8n/n8n-nodes-langchain.openAi",
      "position": [
        2880,
        1632
      ],
      "parameters": {
        "modelId": {
          "__rl": true,
          "mode": "list",
          "value": "gpt-4.1-nano",
          "cachedResultName": "GPT-4.1-NANO"
        },
        "options": {},
        "messages": {
          "values": [
            {
              "role": "system",
              "content": "=You are a financial intent parser.\nYour ONLY task is to convert a natural language finance question into structured JSON.\nYou must:\n- Return ONLY valid JSON.\n- Do NOT include explanations.\n- Do NOT include markdown.\n- Do NOT answer the question.\n- Do NOT guess missing information.\n- Do NOT add extra fields.\n\nThe user's first name is: {{ $('MSG | Telegram Inbound').item.json.message.from.first_name }}\nThe current year is: {{ new Date().getFullYear() }}\nThe current date is: {{ new Date().toISOString().split('T')[0] }}\n\nAlways return the following JSON structure:\n{\n  \"intent\": \"spending_query\",\n  \"time_reference\": \"last_week, this_week, last_month, this_month, last_year, this_year, or null\",\n  \"explicit_start_date\": \"YYYY-MM-DD or null\",\n  \"explicit_end_date\": \"YYYY-MM-DD or null\",\n  \"person\": \"string or null\",\n  \"category\": [\"string\"] or null,\n  \"common_only\": true, false, or null,\n  \"comparison\": \"previous_period, previous_month, previous_year, or null\",\n  \"group_by\": \"category, person, or null\"\n}\n\nInterpretation Rules:\n1. Date Handling (IMPORTANT):\n- If the user mentions a specific month without a year (e.g. \"february\", \"januar\"),\n  assume the current year and set explicit_start_date and explicit_end_date accordingly.\n  Example: \"february\" \u2192 explicit_start_date: \"2026-02-01\", explicit_end_date: \"2026-02-28\"\n- If the user refers to relative time (e.g., last week, this month, last month, this year, etc.), \n  set the correct value in \"time_reference\".\n- DO NOT calculate actual dates for relative time references.\n- Leave explicit_start_date and explicit_end_date as null for relative time references.\n- If the user provides an explicit date range (e.g., 01.01.2026 - 31.01.2026),\n  set explicit_start_date and explicit_end_date accordingly,\n  and set time_reference to null.\n- If no time reference is mentioned \u2192 set all three fields to null.\n\n2. Person Handling:\n- If user says \"I\", \"me\", \"ich\", \"mir\" \u2192 use \"{{ $('MSG | Telegram Inbound').item.json.message.from.first_name }}\"\n- If user says \"we\", \"wir\", \"uns\", \"beide\" \u2192 null (refers to both persons, no filter needed).\n- If a specific name or nickname is mentioned that refers to another person \u2192 use that exact name as written.\n- If no person is mentioned \u2192 null.\n- NEVER invent or guess a person name.\n- The value for \"person\" must always be either the mentioned name/nickname, \"{{ $('MSG | Telegram Inbound').item.json.message.from.first_name }}\", or null.\n\n3. category:\n- If one category is mentioned, return it as a single-element array.\n- If multiple categories are mentioned, return all of them as an array.\n- Use lowercase strings.\n- If none mentioned \u2192 null.\n\n4. Common Expense Handling (VERY IMPORTANT):\nSet \"common_only\" to true if the user explicitly refers to shared expenses.\nThis includes words or phrases such as:\n- shared\n- common\n- joint\n- together\n- gemeinschaftlich\n- gemeinsam\n- gemeinsame ausgaben\n- geteilte ausgaben\n- f\u00fcr uns\n- zusammen\nIf the user explicitly says:\n- \"f\u00fcr gemeinsame Ausgaben\"\n- \"f\u00fcr gemeinsame Kosten\"\nyou MUST set \"common_only\" to true.\nSet \"common_only\" to false if the user explicitly refers to private expenses.\nThis includes:\n- private\n- pers\u00f6nlich\n- eigene ausgaben\n- nur f\u00fcr sich\n- privat\nIf shared/private is NOT explicitly mentioned \u2192 set \"common_only\" to null.\nNever assume.\n\n5. Comparison:\n- If user compares with previous month \u2192 \"previous_month\"\n- If compares with previous period \u2192 \"previous_period\"\n- If compares with previous year \u2192 \"previous_year\"\n- Otherwise \u2192 null\n\n6. Grouping:\n- If user asks for breakdown by category \u2192 \"category\"\n- If breakdown by person \u2192 \"person\"\n- Otherwise \u2192 null\n\nReturn ONLY JSON."
            },
            {
              "content": "={{ $json.message.text }}"
            }
          ]
        }
      },
      "credentials": {
        "openAiApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1.4
    },
    {
      "id": "1f1984d4-cc31-4730-8354-69c6ec425bbe",
      "name": "JS | Extract Intent JSON",
      "type": "n8n-nodes-base.code",
      "position": [
        3232,
        1632
      ],
      "parameters": {
        "jsCode": "const rawContent = $input.first().json.message?.content || $input.first().json.choices?.[0]?.message?.content || '{}';\nlet parsed = {};\ntry {\n  const cleaned = rawContent.replace(/```json|```/g, '').trim();\n  parsed = JSON.parse(cleaned);\n} catch(e) {\n  parsed = { intent: 'spending_query', start_date: null, end_date: null, person: null, category: null, common_only: null, comparison: null, group_by: null };\n}\nreturn [{ json: parsed }];"
      },
      "typeVersion": 2
    },
    {
      "id": "4e830963-ede6-4d56-b328-7f3119040143",
      "name": "GS | Load Expenses",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        9824,
        1440
      ],
      "parameters": {
        "options": {},
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": "gid=0",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/YOUR_SPREADSHEET_ID",
          "cachedResultName": "Sheet1"
        },
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": "YOUR_SPREADSHEET_ID",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/YOUR_SPREADSHEET_ID",
          "cachedResultName": "expenses"
        }
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.4
    },
    {
      "id": "4271bf57-4b8d-4f68-8896-2d55f162161e",
      "name": "GS | Load Categories",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        9824,
        1152
      ],
      "parameters": {
        "options": {},
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": "gid=0",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/YOUR_SPREADSHEET_ID",
          "cachedResultName": "Sheet1"
        },
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": "YOUR_SPREADSHEET_ID",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/YOUR_SPREADSHEET_ID",
          "cachedResultName": "expense_categories"
        }
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.4
    },
    {
      "id": "21d72a8f-e9cd-4fac-b832-f0804777d270",
      "name": "JS | Filter & Aggregate",
      "type": "n8n-nodes-base.code",
      "position": [
        10576,
        1264
      ],
      "parameters": {
        "jsCode": "// =============================\n// Date Engine\n// =============================\n\nconst now = new Date();\n\nfunction normalizeDate(date) {\n  const d = new Date(date);\n  if (isNaN(d)) return null;\n  d.setHours(0,0,0,0);\n  return d;\n}\n\nfunction getDateRange(timeReference) {\n\n  switch (timeReference) {\n\n    case \"this_month\":\n      return {\n        start: normalizeDate(new Date(now.getFullYear(), now.getMonth(), 1)),\n        end: normalizeDate(now)\n      };\n\n    case \"last_month\":\n      return {\n        start: normalizeDate(new Date(now.getFullYear(), now.getMonth() - 1, 1)),\n        end: normalizeDate(new Date(now.getFullYear(), now.getMonth(), 0))\n      };\n\n    case \"this_year\":\n      return {\n        start: normalizeDate(new Date(now.getFullYear(), 0, 1)),\n        end: normalizeDate(now)\n      };\n\n    case \"last_year\":\n      return {\n        start: normalizeDate(new Date(now.getFullYear() - 1, 0, 1)),\n        end: normalizeDate(new Date(now.getFullYear() - 1, 11, 31))\n      };\n\n    case \"last_week\":\n      const day = now.getDay();\n      const diffToMonday = (day + 6) % 7;\n\n      const lastMonday = new Date(now);\n      lastMonday.setDate(now.getDate() - diffToMonday - 7);\n\n      const lastSunday = new Date(lastMonday);\n      lastSunday.setDate(lastMonday.getDate() + 6);\n\n      return {\n        start: normalizeDate(lastMonday),\n        end: normalizeDate(lastSunday)\n      };\n\n    case \"this_week\":\n      const currentDay = now.getDay();\n      const diff = (currentDay + 6) % 7;\n\n      const monday = new Date(now);\n      monday.setDate(now.getDate() - diff);\n\n      return {\n        start: normalizeDate(monday),\n        end: normalizeDate(now)\n      };\n\n    default:\n      return { start: null, end: null };\n  }\n}\n\nfunction formatDate(date) {\n  if (!date) return null;\n  return date.toISOString().split('T')[0];\n}\n\n// =============================\n// Retrieve Intent\n// =============================\n\nconst intent = $('JS | Extract Intent JSON').first().json;\nconst edit3 = $('SET | Assemble Resolved Intent').first().json;\n\n// IMPORTANT: overwrite values if edited\nintent.person = edit3.person || intent.person;\nintent.category = edit3.category || intent.category;\n\n// =============================\n// Load & Normalize Data\n// =============================\n\nconst expenseItems = $('GS | Load Expenses').all().map(i => ({\n  date: i.json.date,\n  amount: parseFloat(i.json.amount) || 0,\n  category: i.json.category?.toLowerCase() || null,\n  description: i.json.description,\n  common_expense: i.json.common_expense,\n  person: i.json.Person?.toLowerCase() || null\n}));\n\n// =============================\n// Date Handling\n// =============================\n\nlet startDate = null;\nlet endDate = null;\n\nif (intent.time_reference) {\n  const range = getDateRange(intent.time_reference);\n  startDate = range.start;\n  endDate = range.end;\n}\n\nif (intent.explicit_start_date) {\n  startDate = normalizeDate(intent.explicit_start_date);\n}\n\nif (intent.explicit_end_date) {\n  endDate = normalizeDate(intent.explicit_end_date);\n}\n\nconst formattedStart = formatDate(startDate);\nconst formattedEnd = formatDate(endDate);\n\n// =============================\n// Other Filters\n// =============================\n\nconst filterPerson = intent.person ? intent.person.toLowerCase() : null;\n\nconst filterCategories = (() => {\n  const c = intent.category;\n  if (!c) return [];\n  if (Array.isArray(c)) {\n    return c.map(x => String(x).trim().toLowerCase()).filter(Boolean);\n  }\n  return [String(c).trim().toLowerCase()].filter(Boolean);\n})();\n\nconst filterCommon = intent.common_only;\nconst groupBy = intent.group_by;\n\n// =============================\n// Apply Filters\n// =============================\n\nconst filtered = expenseItems.filter(row => {\n\n  if (!row.date) return false;\n\n  const rowDate = normalizeDate(row.date);\n  if (!rowDate) return false;\n\n  if (startDate && rowDate < startDate) return false;\n  if (endDate && rowDate > endDate) return false;\n\n  if (filterPerson && row.person !== filterPerson) return false;\n\n  if (filterCategories.length > 0) {\n    if (!row.category) return false;\n    if (!filterCategories.includes(row.category)) return false;\n  }\n\n  if (filterCommon !== null && filterCommon !== undefined) {\n\n    const isCommon =\n      String(row.common_expense).toLowerCase() === 'true' ||\n      row.common_expense === true ||\n      row.common_expense === 1;\n\n    if (filterCommon && !isCommon) return false;\n    if (!filterCommon && isCommon) return false;\n  }\n\n  return true;\n\n});\n\n// =============================\n// Aggregations\n// =============================\n\nconst total = filtered.reduce((sum, row) => sum + row.amount, 0);\n\n// Category breakdown\nlet categoryBreakdown = null;\n\nif (groupBy === \"category\") {\n\n  const breakdown = {};\n\n  filtered.forEach(row => {\n\n    const cat = row.category || \"uncategorized\";\n\n    breakdown[cat] = (breakdown[cat] || 0) + row.amount;\n\n  });\n\n  categoryBreakdown = breakdown;\n}\n\n// Person breakdown\nlet personBreakdown = null;\n\nif (groupBy === \"person\") {\n\n  const breakdown = {};\n\n  filtered.forEach(row => {\n\n    const person = row.person || \"unknown\";\n\n    breakdown[person] = (breakdown[person] || 0) + row.amount;\n\n  });\n\n  personBreakdown = breakdown;\n}\n\n// =============================\n// Return Result\n// =============================\n\nreturn [{\n  json: {\n    intent,\n    total: Math.round(total * 100) / 100,\n    count: filtered.length,\n    categoryBreakdown,\n    personBreakdown,\n    period: {\n      start: formattedStart,\n      end: formattedEnd\n    },\n    filters: {\n      person: intent.person,\n      category: intent.category,\n      common_only: intent.common_only\n    }\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "b24a37e4-d82f-43e6-b343-e91f7f838d19",
      "name": "JS | Format Response Message",
      "type": "n8n-nodes-base.code",
      "position": [
        11056,
        1264
      ],
      "parameters": {
        "jsCode": "const data = $input.first().json;\nconst filters = data.filters || {};\nconst period = data.period || {};\n\nlet lines = [];\n\n// =============================\n// Header\n// =============================\n\nlines.push('\ud83d\udcca *Expense Summary*');\nlines.push('');\n\n// =============================\n// Period Display (NEW)\n// =============================\n\nif (period.start || period.end) {\n  const from = period.start || 'Beginning';\n  const to = period.end || 'Today';\n  lines.push(`\ud83d\udcc5 Period: ${from} \u2192 ${to}`);\n} else {\n  lines.push(`\ud83d\udcc5 Period: All time`);\n}\n\n// =============================\n// Applied filters\n// =============================\n\nif (filters.person) lines.push(`\ud83d\udc64 Person: ${filters.person}`);\nif (filters.category) lines.push(`\ud83c\udff7\ufe0f Category: ${filters.category}`);\n\nif (filters.common_only !== null && filters.common_only !== undefined) {\n  lines.push(`\ud83d\udd17 Common expenses only: ${filters.common_only ? 'Yes' : 'No'}`);\n}\n\nlines.push('');\nlines.push(`\ud83d\udcb0 *Total: \u20ac${data.total.toFixed(2)}*`);\nlines.push(`\ud83d\udcdd Transactions: ${data.count}`);\n\n// =============================\n// Category breakdown\n// =============================\n\nif (data.categoryBreakdown) {\n  lines.push('');\n  lines.push('*By Category:*');\n\n  const sorted = Object.entries(data.categoryBreakdown)\n    .sort((a, b) => b[1] - a[1]);\n\n  sorted.forEach(([cat, amt]) => {\n    const pct = data.total > 0\n      ? ((amt / data.total) * 100).toFixed(1)\n      : 0;\n\n    lines.push(`  \u2022 ${cat}: $${amt.toFixed(2)} (${pct}%)`);\n  });\n}\n\n// =============================\n// Person breakdown\n// =============================\n\nif (data.personBreakdown) {\n  lines.push('');\n  lines.push('*By Person:*');\n\n  const sorted = Object.entries(data.personBreakdown)\n    .sort((a, b) => b[1] - a[1]);\n\n  sorted.forEach(([person, amt]) => {\n    const pct = data.total > 0\n      ? ((amt / data.total) * 100).toFixed(1)\n      : 0;\n\n    lines.push(`  \u2022 ${person}: $${amt.toFixed(2)} (${pct}%)`);\n  });\n}\n\n// =============================\n// Comparison placeholder\n// =============================\n\nif (data.comparison) {\n  lines.push('');\n  lines.push(`\ud83d\udcc8 Comparison: [Feature coming soon]`);\n}\n\n// =============================\n// Empty state\n// =============================\n\nif (data.count === 0) {\n  lines.push('');\n  lines.push('_No expenses found for the given filters._');\n}\n\nreturn [{ json: { message: lines.join('\\n') } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "c7dc9c2c-8bc0-4b0a-a988-27a966b3f814",
      "name": "TG | Send Reply",
      "type": "n8n-nodes-base.telegram",
      "position": [
        11312,
        1264
      ],
      "parameters": {
        "text": "={{ $json.message }}",
        "chatId": "={{ $('MSG | Telegram Inbound').first().json.message.chat.id }}",
        "additionalFields": {
          "parse_mode": "Markdown",
          "appendAttribution": false,
          "reply_to_message_id": "={{ $('MSG | Telegram Inbound').first().json.message.message_id }}"
        }
      },
      "credentials": {
        "telegramApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "4876799b-af52-4218-80aa-7603258ff084",
      "name": "MERGE | Combine Expenses + Categories",
      "type": "n8n-nodes-base.merge",
      "position": [
        10144,
        1264
      ],
      "parameters": {
        "mode": "chooseBranch"
      },
      "typeVersion": 3.2
    },
    {
      "id": "db13a254-5fde-42e4-9e82-2060a04451f0",
      "name": "IF | User Authorized?",
      "type": "n8n-nodes-base.if",
      "position": [
        2528,
        1072
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "or",
          "conditions": [
            {
              "id": "0f85f7d5-8b7e-4bcd-ae10-d7a1785192c4",
              "operator": {
                "type": "number",
                "operation": "equals"
              },
              "leftValue": "={{ $json.message.chat.id }}",
              "rightValue": 1000000000
            },
            {
              "id": "2d18f8cf-2910-4e20-b2f3-16a9ef04e63e",
              "operator": {
                "type": "number",
                "operation": "equals"
              },
              "leftValue": "={{ $json.message.chat.id }}",
              "rightValue": 1000000001
            }
          ]
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "7a76b7d6-f533-4785-b822-8d7ac464d883",
      "name": "SPLIT | Split Categories",
      "type": "n8n-nodes-base.splitOut",
      "position": [
        4016,
        768
      ],
      "parameters": {
        "options": {},
        "fieldToSplitOut": "category"
      },
      "typeVersion": 1
    },
    {
      "id": "8e963faf-3079-447d-bfd9-89eff8da207e",
      "name": "GS | Read Category Mapping",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        4016,
        1072
      ],
      "parameters": {
        "options": {},
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": "gid=0",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/YOUR_SPREADSHEET_ID",
          "cachedResultName": "Sheet1"
        },
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": "YOUR_SPREADSHEET_ID",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/YOUR_SPREADSHEET_ID",
          "cachedResultName": "categories_mapping"
        }
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.7
    },
    {
      "id": "e992cd5e-ba4e-4ab5-b885-14c55546d0a3",
      "name": "MERGE | Join Categories with Mapping",
      "type": "n8n-nodes-base.merge",
      "position": [
        4272,
        768
      ],
      "parameters": {
        "mode": "combineBySql",
        "query": "SELECT * FROM input1 LEFT JOIN input2 ON input1.category = input2.find",
        "options": {}
      },
      "typeVersion": 3.2
    },
    {
      "id": "58c889ef-e918-43d4-a7a3-1a47bf890ffc",
      "name": "TG | Confirm Category Suggestion",
      "type": "n8n-nodes-base.telegram",
      "position": [
        6224,
        368
      ],
      "parameters": {
        "chatId": "={{ $('MSG | Telegram Inbound').item.json.message.chat.id }}",
        "message": "=I couldn\u2019t find the category \"{{ $('IF | Category Known?').item.json.category_new }}\".\n\nDid you mean \"{{ $json.content }}\"?\n\n\u2705 Yes  \n\u274c No",
        "options": {
          "appendAttribution": false
        },
        "operation": "sendAndWait",
        "approvalOptions": {
          "values": {
            "approvalType": "double",
            "approveLabel": "\u2705 Yes",
            "disapproveLabel": "\u274c No"
          }
        }
      },
      "credentials": {
        "telegramApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "fd9c3d1c-223a-44d8-afbf-4773a11d09bd",
      "name": "GS | Read Allowed Categories",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        4464,
        1072
      ],
      "parameters": {
        "options": {},
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": "gid=0",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/YOUR_SPREADSHEET_ID",
          "cachedResultName": "Sheet1"
        },
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": "YOUR_SPREADSHEET_ID",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/YOUR_SPREADSHEET_ID",
          "cachedResultName": "expense_categories"
        }
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.7
    },
    {
      "id": "2f1f1de8-cae3-4792-920c-ba490986019e",
      "name": "SET | Normalize Category",
      "type": "n8n-nodes-base.set",
      "position": [
        4464,
        768
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "8f458013-1006-4634-a1c8-aedff820a48f",
              "name": "category_new",
              "type": "string",
              "value": "={{ $json.replace ?? $json.category }}"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "7a819214-50e5-460a-bd4d-a546eb68dc20",
      "name": "MERGE | Check Category against Allowed",
      "type": "n8n-nodes-base.merge",
      "position": [
        4720,
        768
      ],
      "parameters": {
        "mode": "combineBySql",
        "query": "SELECT * FROM input1\n  \nLEFT JOIN input2 ON input1.category_new = input2.category",
        "options": {}
      },
      "typeVersion": 3.2,
      "alwaysOutputData": false
    },
    {
      "id": "ed0de8c5-8c55-49f2-96a0-973b49b95d6a",
      "name": "IF | Category Known?",
      "type": "n8n-nodes-base.if",
      "position": [
        4960,
        768
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 3,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "e329be08-8c46-4898-9480-5946735ad54d",
              "operator": {
                "type": "string",
                "operation": "notExists",
                "singleValue": true
              },
              "leftValue": "={{ $json.description }}",
              "rightValue": ""
            }
          ]
        }
      },
      "typeVersion": 2.3,
      "alwaysOutputData": false
    },
    {
      "id": "9ecd305a-5e42-43f1-91fa-21fbb0d89e53",
      "name": "LLM | Classify Category",
      "type": "@n8n/n8n-nodes-langchain.openAi",
      "position": [
        5712,
        368
      ],
      "parameters": {
        "modelId": {
          "__rl": true,
          "mode": "list",
          "value": "gpt-4.1-nano",
          "cachedResultName": "GPT-4.1-NANO"
        },
        "options": {},
        "messages": {
          "values": [
            {
              "role": "system",
              "content": "=You are a strict category classification system.\n\nYour task:\nMap the given input category to exactly ONE category from the allowed category list.\n\nRules:\n- You MUST select one category from the allowed list.\n- Do NOT invent new categories.\n- Do NOT return explanations.\n- Do NOT return multiple categories.\n- Output ONLY the exact category name from the allowed list.\n- Matching must be case-insensitive.\n- Choose the closest semantic match if there is no exact match.\n\nInput category:\n{{ $('IF | Category Known?').item.json.category_new }}\n\nAllowed categories:\n{{ $json.category }}"
            }
          ]
        }
      },
      "credentials": {
        "openAiApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1.4
    },
    {
      "id": "73085ca0-da81-4f8f-a232-5680c7d88b88",
      "name": "MERGE | Combine Categories with List",
      "type": "n8n-nodes-base.merge",
      "position": [
        5216,
        672
      ],
      "parameters": {
        "mode": "combine",
        "options": {},
        "combineBy": "combineAll"
      },
      "typeVersion": 3.2
    },
    {
      "id": "19fe45dc-435b-4e70-9b7a-cd7c0bedd41e",
      "name": "SET | Extract LLM Category",
      "type": "n8n-nodes-base.set",
      "position": [
        5968,
        368
      ],
      "parameters": {
        "mode": "raw",
        "options": {},
        "jsonOutput": "={{ $json.message }}"
      },
      "typeVersion": 3.4
    },
    {
      "id": "20b22d0e-35bf-476f-9aba-e2abb14f0377",
      "name": "IF | Category Suggestion Accepted?",
      "type": "n8n-nodes-base.if",
      "position": [
        6464,
        368
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 3,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "4fb343c7-9eb2-4491-b017-3637fb543564",
              "operator": {
                "type": "boolean",
                "operation": "equals"
              },
              "leftValue": "={{ $json.data.approved }}",
              "rightValue": true
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "1d692432-6601-47f1-9bdd-cafd50aeef5c",
      "name": "GS | Save Category Mapping",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        7968,
        368
      ],
      "parameters": {
        "columns": {
          "value": {
            "find": "={{ $json.old }}",
            "replace": "={{ $json.new }}"
          },
          "schema": [
            {
              "id": "find",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "find",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "replace",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "replace",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {},
        "operation": "append",
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": "gid=0",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/YOUR_SPREADSHEET_ID",
          "cachedResultName": "Sheet1"
        },
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": "YOUR_SPREADSHEET_ID",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/YOUR_SPREADSHEET_ID",
          "cachedResultName": "categories_mapping"
        }
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.7
    },
    {
      "id": "750a9069-a57b-4b46-8725-42367602aad2",
      "name": "MERGE | Loop Entry (Category)",
      "type": "n8n-nodes-base.merge",
      "position": [
        5712,
        768
      ],
      "parameters": {},
      "typeVersion": 3.2
    },
    {
      "id": "20498665-76a1-45b8-978f-e92e0fc19ed1",
      "name": "SET | Set Resolved Category",
      "type": "n8n-nodes-base.set",
      "position": [
        5968,
        768
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "47a00b45-f580-4568-9913-b2359e1c1d8c",
              "name": "category_new",
              "type": "string",
              "value": "={{ $json.replace ?? $('IF | Category Known?').item.json.category_new}}"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "e6824c4c-a839-40d0-b334-40f19aad19d0",
      "name": "SET | Assemble Resolved Intent",
      "type": "n8n-nodes-base.set",
      "position": [
        9472,
        1264
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "dc8b496a-4e1a-4ba3-9180-61b77a616b99",
              "name": "intent",
              "type": "string",
              "value": "={{ $('JS | Extract Intent JSON').item.json.intent }}"
            },
            {
              "id": "1e76280f-f49b-4225-ba90-fccc0ba3d9f2",
              "name": "time_reference",
              "type": "string",
              "value": "={{ $('JS | Extract Intent JSON').item.json.time_reference }}"
            },
            {
              "id": "1b10b1a5-e35d-4e13-8a28-945271eb4cd6",
              "name": "explicit_start_date",
              "type": "string",
              "value": "={{ $('JS | Extract Intent JSON').item.json.explicit_start_date }}"
            },
            {
              "id": "51c694b2-c10b-485d-8e1e-dedd797308a9",
              "name": "explicit_end_date",
              "type": "string",
              "value": "={{ $('JS | Extract Intent JSON').item.json.explicit_end_date }}"
            },
            {
              "id": "56a2b084-547f-412f-b534-4c2a97ec52c8",
              "name": "person",
              "type": "string",
              "value": "={{ $json.person_new }}"
            },
            {
              "id": "aa1b02a7-6908-4ba0-b8c2-a74b15516b40",
              "name": "category",
              "type": "array",
              "value": "={{ $json.category_new }}"
            },
            {
              "id": "4bd911ba-a8e3-423c-aa2e-eab59e16a33a",
              "name": "common_only",
              "type": "string",
              "value": "={{ $('JS | Extract Intent JSON').item.json.common_only }}"
            },
            {
              "id": "720e91ea-fc42-48b4-af78-1853736d8720",
              "name": "comparison",
              "type": "string",
              "value": "={{ $('JS | Extract Intent JSON').item.json.comparison }}"
            },
            {
              "id": "27656405-d197-4f6e-b1d8-f90a72351f20",
              "name": "group_by",
              "type": "string",
              "value": "={{ $('JS | Extract Intent JSON').item.json.group_by }}"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "cc31446d-2ffb-4411-afd7-64e69182b448",
      "name": "AGG | Aggregate Category List",
      "type": "n8n-nodes-base.aggregate",
      "position": [
        4720,
        1072
      ],
      "parameters": {
        "options": {},
        "fieldsToAggregate": {
          "fieldToAggregate": [
            {
              "fieldToAggregate": "category"
            }
          ]
        }
      },
      "typeVersion": 1
    },
    {
      "id": "6f2ace60-279f-40ce-8459-53a13a603748",
      "name": "LOOP | Iterate Categories",
      "type": "n8n-nodes-base.splitInBatches",
      "position": [
        5472,
        768
      ],
      "parameters": {
        "options": {}
      },
      "typeVersion": 3
    },
    {
      "id": "165edc36-f6bd-4231-a1e4-bd7c7cd4ecea",
      "name": "SET | Set New+Old Category (LLM)",
      "type": "n8n-nodes-base.set",
      "position": [
        7216,
        368
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "5f52894c-c285-46f0-822d-44665a58555f",
              "name": "new",
              "type": "string",
              "value": "={{ $('SET | Extract LLM Category').item.json.content }}"
            },
            {
              "id": "e02f6b7f-c27d-4620-84e4-346dcb5f26f0",
              "name": "old",
              "type": "string",
              "value": "={{ $('LOOP | Iterate Categories').item.json.category_new }}"
            }
          ]
        }
      },
      "typeVersion": 3.4,
      "alwaysOutputData": true
    },
    {
      "id": "8294d338-81df-4799-8d83-335699eb38d9",
      "name": "JS | Build Category Inline Buttons",
      "type": "n8n-nodes-base.code",
      "position": [
        6720,
        176
      ],
      "parameters": {
        "jsCode": "const items = $('AGG | Aggregate Category List').first().json.category;\nif (!Array.isArray(items)) {\n  throw new Error('Expected category to be an array');\n}\n\nconst buttons = items.map(item => ([{\n  text: String(item),\n  callback_data: String(item)\n}]));\n\nreturn [{\n  json: {\n    inline_keyboard: buttons,\n    resumeUrl: $execution.resumeUrl\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "e7a92e17-a097-4e67-8ba0-aa2073aeefb1",
      "name": "WAIT | Wait for Category Selection",
      "type": "n8n-nodes-base.wait",
      "position": [
        7216,
        176
      ],
      "parameters": {
        "resume": "webhook",
        "options": {},
        "httpMethod": "POST"
      },
      "typeVersion": 1.1
    },
    {
      "id": "d5c9afc3-2245-4a93-9c66-38edc4c0f4a2",
      "name": "HTTP | Send Category Selection Message",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        6960,
        176
      ],
      "parameters": {
        "url": "https://api.telegram.org/bot{{YOUR_BOT_TOKEN}}/sendMessage",
        "method": "POST",
        "options": {},
        "jsonBody": "={\n  \"chat_id\": \"{{ $('MSG | Telegram Inbound').item.json.message.chat.id }}\",\n  \"text\": \"Bitte w\u00e4hle eine Kategorie:<a href='{{ $json.resumeUrl }}'>\u200b</a>\",\n  \"parse_mode\": \"HTML\",\n  \"reply_markup\": {{ JSON.stringify({ \"inline_keyboard\": $json.inline_keyboard }) }}\n}",
        "sendBody": true,
        "specifyBody": "json"
      },
      "typeVersion": 4.3
    },
    {
      "id": "33dba25d-9b17-4da1-8463-92dbad2c0ff3",
      "name": "SET | Read Callback Body (Cat.)",
      "type": "n8n-nodes-base.set",
      "position": [
        7472,
        176
      ],
      "parameters": {
        "mode": "raw",
        "options": {},
        "jsonOutput": "={{ $json.body}}"
      },
      "typeVersion": 3.4
    },
    {
      "id": "e11d27e9-66ea-4cde-a108-3c38b5fea75b",
      "name": "SET | Set New+Old Category (Selection)",
      "type": "n8n-nodes-base.set",
      "position": [
        7472,
        368
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "e34ab1c9-3985-432b-9637-6a695fbf5931",
              "name": "new",
              "type": "string",
              "value": "={{ $json.category }}"
            },
            {
              "id": "b3f6ce98-c6bc-4fea-a581-d1abcd087df4",
              "name": "old",
              "type": "string",
              "value": "={{ $('LOOP | Iterate Categories').item.json.category_new }}"
            }
          ]
        }
      },
      "typeVersion": 3.4,
      "alwaysOutputData": true
    },
    {
      "id": "e765fbf0-81a6-4117-9fbf-8c5c4d0f6c90",
      "name": "MERGE | Combine Category Mapping Entries",
      "type": "n8n-nodes-base.merge",
      "position": [
        7712,
        368
      ],
      "parameters": {},
      "typeVersion": 3.2
    },
    {
      "id": "dada58e6-2abc-4cec-84c2-4bd92994e796",
      "name": "HTTP | Forward Category Selection",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        3312,
        496
      ],
      "parameters": {
        "url": "={{ $json.resumeUrl }}",
        "method": "POST",
        "options": {},
        "jsonBody": "={\n  \"category\": \"{{ $json.category }}\"\n}",
        "sendBody": true,
        "specifyBody": "json"
      },
      "typeVersion": 4.3
    },
    {
      "id": "76197f7e-8f0f-46d5-84b4-c9c6bb735668",
      "name": "TG | Confirm Category Selection",
      "type": "n8n-nodes-base.telegram",
      "position": [
        3504,
        496
      ],
      "parameters": {
        "text": "=Du hast \" {{ $('MSG | Telegram Inbound').item.json.callback_query.data }}\" ausgew\u00e4hlt\"",
        "chatId": "={{ $('MSG | Telegram Inbound').item.json.callback_query.message.chat.id }}",
        "additionalFields": {
          "appendAttribution": false
        }
      },
      "credentials": {
        "telegramApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "883bf86d-3fac-461a-a9b7-dcc0018eb20f",
      "name": "JS | Read Category Callback",
      "type": "n8n-nodes-base.code",
      "position": [
        3104,
        496
      ],
      "parameters": {
        "jsCode": "const callbackData = $json.callback_query.data;\nconst text = $json.callback_query.message.text;\nconst entities = $json.callback_query.message.entities;\n\n// URL aus den message entities auslesen\nconst urlEntity = entities.find(e => e.type === 'text_link');\nconst resumeUrl = urlEntity.url;\n\nreturn [{\n  json: {\n    category: callbackData,\n    resumeUrl: resumeUrl\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "0e0c0fc1-c577-4e31-a62e-2bef9ef22815",
      "name": "IF | Message or Callback?",
      "type": "n8n-nodes-base.if",
      "position": [
        2352,
        1088
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 3,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "69655a65-f86e-4666-8f8f-d9cc2fda74b2",
              "operator": {
                "type": "object",
                "operation": "exists",
                "singleValue": true
              },
              "leftValue": "={{ $json.message }}",
              "rightValue": "="
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "4233a9ba-e3a9-49c7-8f07-1d36fa3fec81",
      "name": "IF | Category Present?",
      "type": "n8n-nodes-base.if",
      "position": [
        3824,
        976
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 3,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "b875ecb6-7b43-4083-8591-bec82fb6a22b",
              "operator": {
                "type": "string",
                "operation": "notExists",
                "singleValue": true
              },
              "leftValue": "={{ $json.category[0] }}",
              "rightValue": ""
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "449ced93-3de6-4bf4-8e71-cb0acb48a43e",
      "name": "MERGE | Combine Category + Person",
      "type": "n8n-nodes-base.merge",
      "position": [
        8832,
        1248
      ],
      "parameters": {
        "numberInputs": 3
      },
      "typeVersion": 3.2
    },
    {
      "id": "f98b58b7-483e-495b-95c1-aa5a9fa457c4",
      "name": "AGG | Aggregate Resolved Categories",
      "type": "n8n-nodes-base.aggregate",
      "position": [
        6224,
        768
      ],
      "parameters": {
        "options": {},
        "fieldsToAggregate": {
          "fieldToAggregate": [
            {
              "fieldToAggregate": "category_new"
            }
          ]
        }
      },
      "typeVersion": 1
    },
    {
      "id": "50f4587d-754a-4bb3-888b-b6e084ee6237",
      "name": "IF | Person Present?",
      "type": "n8n-nodes-base.if",
      "position": [
        3760,
        2096
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 3,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "b875ecb6-7b43-4083-8591-bec82fb6a22b",
              "operator": {
                "type": "string",
                "operation": "notExists",
                "singleValue": true
              },
              "leftValue": "={{ $json.person }}",
              "rightValue": ""
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "85f203b8-bdf7-4346-aceb-30a8388dc4cf",
      "name": "SPLIT | Split Persons",
      "type": "n8n-nodes-base.splitOut",
      "position": [
        3968,
        2064
      ],
      "parameters": {
        "options": {},
        "fieldToSplitOut": "person"
      },
      "typeVersion": 1
    },
    {
      "id": "7657a339-c559-4327-bc25-5fda098783f3",
      "name": "MERGE | Join Persons with Mapping",
      "type": "n8n-nodes-base.merge",
      "position": [
        4224,
        2064
      ],
      "parameters": {
        "mode": "combineBySql",
        "query": "SELECT * FROM input1 LEFT JOIN input2 ON input1.category = input2.find",
        "options": {}
      },
      "typeVersion": 3.2
    },
    {
      "id": "286c2311-d948-4dd0-92bb-81231cb895ea",
      "name": "SET | Normalize Person",
      "type": "n8n-nodes-base.set",
      "position": [
        4416,
        2064
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "8f458013-1006-4634-a1c8-aedff820a48f",
              "name": "person_new",
              "type": "string",
              "value": "={{ $json.replace ?? $json.person }}"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "fa32c88e-fb58-483a-8159-bc68edbeebcd",
      "name": "MERGE | Check Person against Allowed",
      "type": "n8n-nodes-base.merge",
      "position": [
        4704,
        2048
      ],
      "parameters": {
        "mode": "combineBySql",
        "query": "SELECT * FROM input1\n  \nLEFT JOIN input2 ON input1.person_new = input2.person",
        "options": {}
      },
      "typeVersion": 3.2,
      "alwaysOutputData": false
    },
    {
      "id": "8ed005b0-aa2a-4052-868a-424d6df8d83e",
      "name": "IF | Person Known?",
      "type": "n8n-nodes-base.if",
      "position": [
        4944,
        2048
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 3,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "e329be08-8c46-4898-9480-5946735ad54d",
              "operator": {
                "type": "string",
                "operation": "notExists",
                "singleValue": true
              },
              "leftValue": "={{ $json.description }}",
              "rightValue": ""
            }
          ]
        }
      },
      "typeVersion": 2.3,
      "alwaysOutputData": false
    },
    {
      "id": "2f3cf76a-d852-4479-a91a-4d93bdcb8e28",
      "name": "MERGE | Combine Persons with List",
      "type": "n8n-nodes-base.merge",
      "position": [
        5200,
        2048
      ],
      "parameters": {
        "mode": "combine",
        "options": {},
        "combineBy": "combineAll"
      },
      "typeVersion": 3.2
    },
    {
      "id": "9db7a679-849e-46a4-8c12-9c9296e00d4c",
      "name": "AGG | Aggregate Person List",
      "type": "n8n-nodes-base.aggregate",
      "position": [
        4912,
        2352
      ],
      "parameters": {
        "options": {},
        "fieldsToAggregate": {
          "fieldToAggregate": [
            {
              "fieldToAggregate": "person"
            }
          ]
        }
      },
      "typeVersion": 1
    },
    {
      "id": "82d59267-2594-498f-863b-f35116802032",
      "name": "GS | Read Person Mapping",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        3968,
        2368
      ],
      "parameters": {
        "options": {},
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": "gid=0",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/YOUR_SPREADSHEET_ID",
          "cachedResultName": "Sheet1"
        },
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": "YOUR_SPREADSHEET_ID",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/YOUR_SPREADSHEET_ID",
          "cachedResultName": "person_mapping"
        }
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.7
    },
    {
      "id": "16747ce4-9d18-438d-a8df-df00508908c1",
      "name": "GS | Read Allowed Persons",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        4416,
        2368
      ],
      "parameters": {
        "options": {},
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": 1157427829,
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/YOUR_SPREADSHEET_ID",
          "cachedResultName": "list_persons"
        },
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": "YOUR_SPREADSHEET_ID",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/YOUR_SPREADSHEET_ID",
          "cachedResultName": "person_mapping"
        }
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.7
    },
    {
      "id": "fa8b6b35-8747-40b8-9639-5131f279c0cb",
      "name": "TG | Confirm Person Suggestion",
      "type": "n8n-nodes-base.telegram",
      "position": [
        6208,
        2448
      ],
      "parameters": {
        "chatId": "={{ $('MSG | Telegram Inbound').item.json.message.chat.id }}",
        "message": "=I couldn\u2019t find the person \"{{ $('IF | Person Known?').item.json.person_new }}\".\n\nDid you mean \"{{ $json.content }}\"?\n\n\u2705 Yes  \n\u274c No",
        "options": {
          "appendAttribution": false
        },
        "operation": "sendAndWait",
        "approvalOptions": {
          "values": {
            "approvalType": "double",
            "approveLabel": "\u2705 Yes",
            "disapproveLabel": "\u274c No"
          }
        }
      },
      "credentials": {
        "telegramApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "fa9fc8ee-c15e-4f4f-9af2-8a5dd626d788",
      "name": "LLM | Classify Person",
      "type": "@n8n/n8n-nodes-langchain.openAi",
      "position": [
        5696,
        2448
      ],
      "parameters": {
        "modelId": {
          "__rl": true,
          "mode": "list",
          "value": "gpt-4.1-nano",
          "cachedResultName": "GPT-4.1-NANO"
        },
        "options": {},
        "messages": {
          "values": [
            {
              "role": "system",
              "content": "=You are a strict person classification system.\n\nYour task:\nMap the given input name to exactly ONE person from the allowed person list.\n\nRules:\n\n* You MUST select one person from the allowed list.\n* Do NOT invent new persons.\n* Do NOT return explanations.\n* Do NOT return multiple persons.\n* Output ONLY the exact person name from the allowed list.\n* Matching must be case-insensitive.\n* Choose the closest semantic match if there is no exact match.\n\nInput name:\n{{ $('IF | Person Known?').item.json.person_new }}\n\nAllowed persons:\n{{ $json.person }}\n"
            }
          ]
        }
      },
      "credentials": {
        "openAiApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1.4
    },
    {
      "id": "ef710337-260f-495c-97aa-7ccb5420b7dc",
      "name": "SET | Extract LLM Person",
      "type": "n8n-nodes-base.set",
      "position": [
        5952,
        2448
      ],
      "parameters": {
        "mode": "raw",
        "options": {},
        "jsonOutput": "={{ $json.message }}"
      },
      "typeVersion": 3.4
    },
    {
      "id": "fd5713f9-9501-420f-9d12-bb8b84fda620",
      "name": "IF | Person Suggestion Accepted?",
      "type": "n8n-nodes-base.if",
      "position": [
        6448,
        2448
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 3,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "4fb343c7-9eb2-4491-b017-3637fb543564",
              "operator": {
                "type": "boolean",
                "operation": "equals"
              },
              "leftValue": "={{ $json.data.approved }}",
              "rightValue": true
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "262227e8-5564-4caf-a833-edc83bd36894",
      "name": "GS | Save Person Mapping",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        8176,
        1904
      ],
      "parameters": {
        "columns": {
          "value": {
            "find": "={{ $json.old }}",
            "replace": "={{ $json.new }}"
          },
          "schema": [
            {
              "id": "find",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "find",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "replace",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "replace",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {},
        "operation": "append",
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": "gid=0",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/YOUR_SPREADSHEET_ID",
          "cachedResultName": "Sheet1"
        },
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": "YOUR_SPREADSHEET_ID",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/YOUR_SPREADSHEET_ID",
          "cachedResultName": "person_mapping"
        }
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.7
    },
    {
      "id": "e6850bcd-ec85-4a14-ae06-73878206a278",
      "name": "LOOP | Iterate Persons",
      "type": "n8n-nodes-base.splitInBatches",
      "position": [
        5456,
        2048
      ],
      "parameters": {
        "options": {}
      },
      "typeVersion": 3
    },
    {
      "id": "741e158c-14ed-4ff1-aba3-27b0f648ab20",
      "name": "SET | Set New+Old Person (LLM)",
      "type": "n8n-nodes-base.set",
      "position": [
        7664,
        2304
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "5f52894c-c285-46f0-822d-44665a58555f",
              "name": "new",
              "type": "string",
              "value": "={{ $('SET | Extract LLM Person').item.json.content }}"
            },
            {
              "id": "e02f6b7f-c27d-4620-84e4-346dcb5f26f0",
              "name": "old",
              "type": "string",
              "value": "={{ $('LOOP | Iterate Persons').item.json.person_new }}"
            }
          ]
        }
      },
      "typeVersion": 3.4,
      "alwaysOutputData": true
    },
    {
      "id": "41280f1e-9534-4947-908a-41a7caef3397",
      "name": "JS | Build Person Inline Buttons",
      "type": "n8n-nodes-base.code",
      "position": [
        6672,
        1712
      ],
      "parameters": {
        "jsCode": "const items = $('AGG | Aggregate Person List').first().json.person;\n\nif (!Array.isArray(items)) {\n  throw new Error('Expected person to be an array');\n}\n\nconst buttons = items.map(item => ([{\n  text: String(item),\n  callback_data: String(item)\n}]));\n\nreturn [{\n  json: {\n    inline_keyboard: buttons,\n    resumeUrl: $execution.resumeUrl\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "5f9c0563-ee99-4147-9081-f499af2bb507",
      "name": "WAIT | Wait for Person Selection",
      "type": "n8n-nodes-base.wait",
      "position": [
        7168,
        1712
      ],
      "parameters": {
        "resume": "webhook",
        "options": {},
        "httpMethod": "POST"
      },
      "typeVersion": 1.1
    },
    {
      "id": "5b86154e-57b0-4b2d-a7d7-1e11cc878b67",
      "name": "HTTP | Send Person Selection Message",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        6912,
        1712
      ],
      "parameters": {
        "url": "https://api.telegram.org/bot{{YOUR_BOT_TOKEN}}/sendMessage",
        "method": "POST",
        "options": {},
        "jsonBody": "={\n  \"chat_id\": \"{{ $('MSG | Telegram Inbound').item.json.message.chat.id }}\",\n  \"text\": \"Bitte w\u00e4hle eine Person:<a href='{{ $json.resumeUrl }}'>\u200b</a>\",\n  \"parse_mode\": \"HTML\",\n  \"reply_markup\": {{ JSON.stringify({ \"inline_keyboard\": $json.inline_keyboard }) }}\n}",
        "sendBody": true,
        "specifyBody": "json"
      },
      "typeVersion": 4.3
    },
    {
      "id": "b879f1c5-ffb3-45b6-b7f5-1b772b431643",
      "name": "SET | Read Callback Body (Person)",
      "type": "n8n-nodes-base.set",
      "position": [
        7424,
        1712
      ],
      "parameters": {
        "mode": "raw",
        "options": {},
        "jsonOutput": "={{ $json.body}}"
      },
      "typeVersion": 3.4
    },
    {
      "id": "1a9aa8de-b20f-4cac-9fde-9d889caee19e",
      "name": "SET | Set New+Old Person (Selection)",
      "type": "n8n-nodes-base.set",
      "position": [
        7664,
        1712
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "e34ab1c9-3985-432b-9637-6a695fbf5931",
              "name": "new",
              "type": "string",
              "value": "={{ $json.person }}"
            },
            {
              "id": "b3f6ce98-c6bc-4fea-a581-d1abcd087df4",
              "name": "old",
              "type": "string",
              "value": "={{ $('LOOP | Iterate Persons').item.json.person_new }}"
            }
          ]
        }
      },
      "typeVersion": 3.4,
      "alwaysOutputData": true
    },
    {
      "id": "205a6d3a-9108-4b8b-9698-5ba8db844f88",
      "name": "MERGE | Combine Person Mapping Entries",
      "type": "n8n-nodes-base.merge",
      "position": [
        7920,
        1904
      ],
      "parameters": {},
      "typeVersion": 3.2
    },
    {
      "id": "eaee6930-3da6-40a9-ba0f-84ab8543b451",
      "name": "MERGE | Loop Entry (Person)",
      "type": "n8n-nodes-base.merge",
      "position": [
        5696,
        2256
      ],
      "parameters": {},
      "typeVersion": 3.2
    },
    {
      "id": "e2241bb1-eae6-4311-909c-a67bb2bd52fe",
      "name": "SET | Set Resolved Person",
      "type": "n8n-nodes-base.set",
      "position": [
        6208,
        2256
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "47a00b45-f580-4568-9913-b2359e1c1d8c",
              "name": "person_new",
              "type": "string",
              "value": "={{ $json.replace ?? $('IF | Person Known?').item.json.person_new}}"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "9a37179d-2d3c-48e3-9ce1-1624b626b6fd",
      "name": "MERGE | Combine Person Mapping",
      "type": "n8n-nodes-base.merge",
      "position": [
        6448,
        2048
      ],
      "parameters": {},
      "typeVersion": 3.2
    },
    {
      "id": "183d2e80-d18b-403e-a3f0-86d41fd0c0b1",
      "name": "IF | Category or Person Callback?",
      "type": "n8n-nodes-base.if",
      "position": [
        2816,
        576
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 3,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "a6650d78-63a0-4bc4-ac46-e076eba14def",
              "operator": {
                "type": "string",
                "operation": "contains"
              },
              "leftValue": "{{json.message.text}}",
              "rightValue": "Kategorie"
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "0ac9e7b3-a794-4a42-b48b-1d42ba317bbc",
      "name": "HTTP | Forward Person Selection",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        3312,
        688
      ],
      "parameters": {
        "url": "={{ $json.resumeUrl }}",
        "method": "POST",
        "options": {},
        "jsonBody": "={\n  \"person\": \"{{ $json.person }}\"\n}",
        "sendBody": true,
        "specifyBody": "json"
      },
      "typeVersion": 4.3
    },
    {
      "id": "36db9da8-b32c-4b48-b7d4-6871eed49cb0",
      "name": "TG | Confirm Person Selection",
      "type": "n8n-nodes-base.telegram",
      "position": [
        3504,
        688
      ],
      "parameters": {
        "text": "=Du hast \" {{ $('MSG | Telegram Inbound').item.json.callback_query.data }}\" ausgew\u00e4hlt\"",
        "chatId": "={{ $('MSG | Telegram Inbound').item.json.callback_query.message.chat.id }}",
        "additionalFields": {
          "appendAttribution": false
        }
      },
      "credentials": {
        "telegramApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "17fceb64-ac93-42b7-af34-fc5f1d0cb3d2",
      "name": "JS | Read Person Callback",
      "type": "n8n-nodes-base.code",
      "position": [
        3104,
        688
      ],
      "parameters": {
        "jsCode": "const callbackData = $json.callback_query.data;\nconst text = $json.callback_query.message.text;\nconst entities = $json.callback_query.message.entities;\n\n// URL aus den message entities auslesen\nconst urlEntity = entities.find(e => e.type === 'text_link');\nconst resumeUrl = urlEntity.url;\n\nreturn [{\n  json: {\n    person: callbackData,\n    resumeUrl: resumeUrl\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "a742d127-062b-4f7d-8c4d-0b089439508e",
      "name": "JS | Merge Intent Fields",
      "type": "n8n-nodes-base.code",
      "position": [
        9168,
        1264
      ],
      "parameters": {
        "jsCode": "const items = $input.all();\n\nconst result = {};\n\nfor (const item of items) {\n  for (const [key, value] of Object.entries(item.json)) {\n    if (\n      value !== undefined &&\n      value !== null &&\n      value !== \"\" &&\n      value !== \"undefined\"\n    ) {\n      result[key] = value;\n    }\n  }\n}\n\nreturn [{\n  json: result,\n  pairedItem: { item: 0 }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "f4353b36-c5f3-4e13-baf8-a257e81fbf43",
      "name": "StickyNote_Layer1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1968,
        0
      ],
      "parameters": {
        "color": 5,
        "width": 1740,
        "height": 1288,
        "content": "## \ud83d\udfe2 LAYER 1 \u2014 INPUT\nReceives incoming Telegram messages & callbacks.\nAuthorizes users and routes by message type.\n\n---\n\n\u2699\ufe0f **ACTION REQUIRED**\n\n**1. Connect Telegram Credential**\nOpen the `MSG | Telegram Inbound` node and connect your Telegram Bot credential.\nDo the same for all other `TG |` nodes in this layer.\n\u2192 *Nodes: MSG | Telegram Inbound, TG | Confirm Category Selection, TG | Confirm Person Selection*\n\n**2. Add authorized Chat IDs**\nOpen `IF | User Authorized?` and replace the placeholder values (`1000000000`, `1000000001`) with the real Telegram Chat IDs of all users who should have access.\nEach person can find their Chat ID by messaging @userinfobot on Telegram.\n"
      },
      "typeVersion": 1
    },
    {
      "id": "b6684ba8-b5ab-41fe-8c1c-9ec20255983d",
      "name": "StickyNote_Layer2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1968,
        1312
      ],
      "parameters": {
        "width": 1730,
        "height": 1284,
        "content": "## \ud83d\udd35 LAYER 2 \u2014 INTENT PARSING\n\n---\n\n\u2699\ufe0f **ACTION REQUIRED**\n\n**1. Connect OpenAI Credential**\nOpen the `LLM | Parse Intent` node and connect your OpenAI API credential."
      },
      "typeVersion": 1
    },
    {
      "id": "c6ddda36-1b63-428b-932d-2d9c857aa12d",
      "name": "StickyNote_Layer3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        3712,
        0
      ],
      "parameters": {
        "color": 3,
        "width": 4970,
        "height": 1296,
        "content": "## \ud83d\udfe1 LAYER 3a \u2014 ENTITY RESOLUTION: CATEGORIES\nResolves raw category strings to canonical names via mapping sheets.\nUnknown entities \u2192 LLM classification \u2192 user confirmation \u2192 alias saved.\n\n---\n\n\u2699\ufe0f **ACTION REQUIRED**\n\n**1. Connect your `categories_mapping` Google Sheet**\nOpen `GS | Read Category Mapping` and `GS | Save Category Mapping`.\nReplace `YOUR_SPREADSHEET_ID` with the ID of your `categories_mapping` sheet.\nRequired columns: `find` \u00b7 `replace`\n\n**2. Connect your `expense_categories` Google Sheet**\nOpen `GS | Read Allowed Categories`.\nReplace `YOUR_SPREADSHEET_ID` with the ID of your `expense_categories` sheet.\nRequired columns: `category` \u00b7 `description` \u00b7 `examples`\n\n**3. Connect OpenAI Credential**\nOpen `LLM | Classify Category` and connect your OpenAI API credential.\n\n**4. Connect Telegram Credential**\nOpen `TG | Confirm Category Suggestion` and connect your Telegram Bot credential.\n\n**5. Set your Bot Token in HTTP nodes**\nOpen `HTTP | Send Category Selection Message`.\nReplace `{{YOUR_BOT_TOKEN}}` in the URL with your actual Telegram Bot Token."
      },
      "typeVersion": 1
    },
    {
      "id": "90172522-bfdb-4fa7-8ef3-48c2ee914994",
      "name": "StickyNote_Layer4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        8688,
        0
      ],
      "parameters": {
        "color": 4,
        "width": 1712,
        "height": 2592,
        "content": "## \ud83d\udfe0 LAYER 4 \u2014 QUERY ENGINE\nLoads expense data from Google Sheets.\nMerges resolved intent with raw data for filtering.\n\n---\n\n\u2699\ufe0f **ACTION REQUIRED**\n\n**1. Connect your `expenses` Google Sheet**\nOpen `GS | Load Expenses` and replace `YOUR_SPREADSHEET_ID` with your expenses sheet ID.\nRequired columns: `date` \u00b7 `amount` \u00b7 `category` \u00b7 `description` \u00b7 `common_expense` \u00b7 `Person`\n\n**2. Connect your `expense_categories` Google Sheet**\nOpen `GS | Load Categories` and replace `YOUR_SPREADSHEET_ID` with your categories sheet ID.\nRequired columns: `category` \u00b7 `description` \u00b7 `examples`"
      },
      "typeVersion": 1
    },
    {
      "id": "4e94aa93-bca3-4087-b52c-de55b3f7be23",
      "name": "StickyNote_Layer5",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        10400,
        0
      ],
      "parameters": {
        "color": 2,
        "width": 564,
        "height": 2592,
        "content": "## \ud83d\udd34 LAYER 5 \u2014 ANALYTICS & AGGREGATION\nApplies date, person, category and common_only filters.\nComputes totals, category breakdown and person breakdown.\n\n---\n\n\u2699\ufe0f **ACTION REQUIRED**\n\nNo setup required for this layer.\nThe `JS | Filter & Aggregate` node works automatically based on the resolved intent from the previous layers.\nIf you add custom columns to your expenses sheet, update the filter logic in this node accordingly."
      },
      "typeVersion": 1
    },
    {
      "id": "a566ae23-972c-4c15-aa4d-b2ea0e14c8f4",
      "name": "StickyNote_Layer6",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        10976,
        0
      ],
      "parameters": {
        "color": 6,
        "width": 560,
        "height": 2592,
        "content": "## \ud83d\udfe3 LAYER 6 \u2014 RESPONSE\nFormats the final message and sends it back via Telegram.\n\n---\n\n\u2699\ufe0f **ACTION REQUIRED**\n\n**1. Connect Telegram Credential**\nOpen `TG | Send Reply` and connect your Telegram Bot credential.\n\n**2. Adjust the message format (optional)**\nOpen `JS | Format Response Message` to customise the output format, currency symbol, or language of the reply message."
      },
      "typeVersion": 1
    },
    {
      "id": "ed4de5df-0c7f-49e6-9580-9bcec3a29756",
      "name": "StickyNote_Layer",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        3712,
        1296
      ],
      "parameters": {
        "color": 6,
        "width": 4970,
        "height": 1296,
        "content": "## \ud83d\udfe1 LAYER 3b \u2014 ENTITY RESOLUTION: PERSONS\n\n\n---\n\n\u2699\ufe0f **ACTION REQUIRED**\n\n**1. Connect your `person_mapping` Google Sheet**\nOpen `GS | Read Person Mapping` and `GS | Save Person Mapping`.\nReplace `YOUR_SPREADSHEET_ID` with the ID of your `person_mapping` sheet.\nRequired columns: `find` \u00b7 `replace`\n\n**2. Connect your `list_persons` sheet**\nOpen `GS | Read Allowed Persons`.\nThis reads from the `list_persons` tab of the same `person_mapping` spreadsheet.\nRequired column: `person`\n\n**3. Connect OpenAI Credential**\nOpen `LLM | Classify Person` and connect your OpenAI API credential.\n\n**4. Connect Telegram Credential**\nOpen `TG | Confirm Person Suggestion` and connect your Telegram Bot credential.\n\n**5. Set your Bot Token in HTTP nodes**\nOpen `HTTP | Send Person Selection Message`.\nReplace `{{YOUR_BOT_TOKEN}}` in the URL with your actual Telegram Bot Token."
      },
      "typeVersion": 1
    },
    {
      "id": "453a9f82-a297-4fb6-adc1-08f2e5619986",
      "name": "DOC | Layer 1 \u2014 Input",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1968,
        464
      ],
      "parameters": {
        "color": 5,
        "width": 540,
        "height": 500,
        "content": "\n\n**Purpose:** Entry point of the workflow. Receives all incoming Telegram events and routes them to the correct processing path.\n\n**What happens here:**\n- `MSG | Telegram Inbound` \u2014 Listens for incoming Telegram messages and callback queries (inline button taps).\n- `IF | Message or Callback?` \u2014 Splits the flow: regular text messages go to intent parsing, callback queries (button responses) are handled separately.\n- `IF | User Authorized?` \u2014 Checks whether the sender's chat ID is on the allowlist. Unauthorized users are silently dropped.\n- `IF | Category or Person Callback?` \u2014 Determines whether an incoming callback is a category or person selection and routes accordingly.\n- `JS | Read Category Callback` / `JS | Read Person Callback` \u2014 Extracts the selected value and the resume URL from the callback payload.\n- `HTTP | Forward Category/Person Selection` \u2014 Resumes the waiting workflow branch by POSTing the selected value to the internal webhook.\n- `TG | Confirm Category/Person Selection` \u2014 Sends a short confirmation message to the user acknowledging their selection."
      },
      "typeVersion": 1
    },
    {
      "id": "a1283c4a-bad8-4116-a97c-0d2bbaa0f5ef",
      "name": "DOC | Layer 2 \u2014 Intent Parsing",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1984,
        1664
      ],
      "parameters": {
        "width": 412,
        "height": 392,
        "content": "\n**Purpose:** Converts the user's free-text message into a structured JSON object that all downstream layers can work with.\n\n**What happens here:**\n- `LLM | Parse Intent` \u2014 Sends the raw message to GPT-4.1-nano with a strict system prompt. Returns a JSON object containing: intent, time_reference, explicit dates, person, category, common_only flag, comparison type and group_by dimension.\n- `JS | Extract Intent JSON` \u2014 Cleans the raw LLM response (strips markdown fences if present) and parses it into a proper JSON node output. Falls back to a safe empty-intent object if parsing fails."
      },
      "typeVersion": 1
    },
    {
      "id": "f593b6d4-1dce-475b-928d-7c9850bda508",
      "name": "DOC | Layer 3a \u2014 Entity Resolution: Categories",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        4384,
        128
      ],
      "parameters": {
        "color": 3,
        "width": 540,
        "height": 644,
        "content": "\n\n**Purpose:** Resolves raw category strings from the intent into valid canonical category names as defined in the categories master sheet.\n\n**What happens here:**\n- `IF | Category Present?` \u2014 Skips the entire branch if no category was mentioned.\n- `SPLIT | Split Categories` \u2014 Splits multi-category intents into individual items for per-item processing.\n- `GS | Read Category Mapping` \u2014 Loads the alias\u2192canonical mapping sheet (e.g. \"essen\" \u2192 \"food\").\n- `MERGE | Join Categories with Mapping` + `SET | Normalize Category` \u2014 Applies alias replacement where available, keeps original otherwise.\n- `GS | Read Allowed Categories` + `MERGE | Check Category against Allowed` \u2014 Validates the normalised category against the master list.\n- `IF | Category Known?` \u2014 Known categories pass through directly; unknown ones enter the LLM resolution loop.\n- `LOOP | Iterate Categories` \u2014 Processes unresolved categories one by one.\n- `LLM | Classify Category` \u2014 GPT maps the unknown input to the closest canonical category from the allowed list.\n- `TG | Confirm Category Suggestion` \u2014 Sends the LLM suggestion to the user as a Yes/No confirmation.\n- `IF | Category Suggestion Accepted?` \u2014 On confirmation the mapping is saved; otherwise an inline-button picker is shown (`JS | Build Category Inline Buttons` \u2192 `HTTP | Send Category Selection Message` \u2192 `WAIT | Wait for Category Selection`).\n- `GS | Save Category Mapping` \u2014 Persists the newly learned alias for future queries."
      },
      "typeVersion": 1
    },
    {
      "id": "1b9d5cb8-c8b9-4afd-919f-ef51ffbd2109",
      "name": "DOC | Layer 3b \u2014 Entity Resolution: Persons",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        4448,
        1376
      ],
      "parameters": {
        "color": 6,
        "width": 572,
        "height": 556,
        "content": "**Purpose:** Mirrors Layer 3a but for person names \u2014 resolves nicknames and unknown names to canonical person records.\n\n**What happens here:**\n- `IF | Person Present?` \u2014 Skips the branch entirely when no person was specified.\n- `SPLIT | Split Persons` \u2014 Splits the person field into individual items.\n- `GS | Read Person Mapping` \u2014 Loads the alias\u2192canonical person mapping (e.g. \"nickname\" \u2192 \"Full Name\").\n- `MERGE | Join Persons with Mapping` + `SET | Normalize Person` \u2014 Applies alias replacement where available.\n- `GS | Read Allowed Persons` + `MERGE | Check Person against Allowed` \u2014 Validates the resolved name against the known persons list.\n- `IF | Person Known?` \u2014 Known persons pass through; unknown ones enter the LLM resolution loop.\n- `LOOP | Iterate Persons` \u2014 Processes unresolved person names one at a time.\n- `LLM | Classify Person` \u2014 GPT maps the unknown name to the nearest known person from the allowed list.\n- `TG | Confirm Person Suggestion` \u2014 Asks the user to confirm the suggestion via Yes/No button.\n- `IF | Person Suggestion Accepted?` \u2014 On confirmation the mapping is saved; otherwise a button picker is shown (`JS | Build Person Inline Buttons` \u2192 `HTTP | Send Person Selection Message` \u2192 `WAIT | Wait for Person Selection`).\n- `GS | Save Person Mapping` \u2014 Stores the new alias for future use."
      },
      "typeVersion": 1
    },
    {
      "id": "c7f0bb66-e951-4962-ac01-999e805c887c",
      "name": "DOC | Layer 4 \u2014 Query Engine",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        8704,
        416
      ],
      "parameters": {
        "color": 4,
        "width": 524,
        "height": 428,
        "content": "\n**Purpose:** Assembles the fully resolved intent and loads raw expense data from Google Sheets, ready for filtering.\n\n**What happens here:**\n- `SET | Assemble Resolved Intent` \u2014 Merges canonical category and person values back into the original intent object, producing one clean query descriptor.\n- `GS | Load Expenses` \u2014 Reads all rows from the expenses Google Sheet (date, amount, category, person, common_expense flag).\n- `GS | Load Categories` \u2014 Loads the categories reference sheet in parallel (used for validation during filtering).\n- `MERGE | Combine Expenses + Categories` \u2014 Combines both data sources into a single output so the next layer has everything it needs."
      },
      "typeVersion": 1
    },
    {
      "id": "4f17b844-0e95-469e-a6b3-b33edf5b96f7",
      "name": "DOC | Layer 5 \u2014 Analytics & Aggregation",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        10448,
        480
      ],
      "parameters": {
        "color": 2,
        "width": 348,
        "height": 340,
        "content": "\n**Purpose:** Applies all filters from the resolved intent to the expense data and computes the requested aggregations.\n\n**What happens here:**\n- `JS | Filter & Aggregate` \u2014 Single code node that does everything: resolves relative date references (this_week, last_month, \u2026) into concrete ranges, applies person / category / common_only filters row by row, then computes total amount, transaction count, and optional breakdowns by category or person (driven by the group_by field in the intent)."
      },
      "typeVersion": 1
    },
    {
      "id": "6d8d1b8c-93db-4374-9240-b01174d354d5",
      "name": "DOC | Layer 6 \u2014 Response",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        11008,
        128
      ],
      "parameters": {
        "color": 6,
        "width": 460,
        "height": 308,
        "content": "**Purpose:** Formats the aggregation result into a human-readable Telegram message and delivers it back to the user.\n\n**What happens here:**\n- `JS | Format Response Message` \u2014 Builds the final message string: period header, applied filters, total amount, transaction count, and \u2014 if requested \u2014 a sorted category or person breakdown with percentage shares. Handles the empty-result case gracefully.\n- `TG | Send Reply` \u2014 Sends the formatted message as a Markdown reply to the original chat, referencing the triggering message ID so it appears as a thread reply."
      },
      "typeVersion": 1
    },
    {
      "id": "9c55a9fc-45aa-4ac4-a94f-7736abee4e73",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1008,
        0
      ],
      "parameters": {
        "color": 7,
        "width": 912,
        "height": 2576,
        "content": "# \ud83d\udd04 WORKFLOW OVERVIEW\n\n## \ud83d\udfe9 Input & Security (Layer 1)\n\nEvery incoming Telegram message or button tap is received by the trigger.\n\nThe workflow checks whether the sender's **Chat ID** is on the approved list \u2014 unauthorized users are silently ignored.\n\nCallback responses (inline button taps for category or person disambiguation) are also handled here and routed back into the correct waiting branch.\n\n---\n\n## \ud83d\udfe6 Intent Parsing (Layer 2)\n\nThe raw message is sent to **GPT-4.1-nano** with a strict system prompt.\n\nThe model returns a structured JSON object containing:\n\n- `intent`\n- `time_reference`\n- `explicit_date_range`\n- `person`\n- `category`\n- `common_only`\n- `comparison_type`\n- `group_by`\n\nA fallback **Code Node** cleans the response and handles parsing errors gracefully.\n\n---\n\n## \ud83d\udfe8 Entity Resolution\n\n### Layer 3a \u2014 Categories  \n### Layer 3b \u2014 Persons\n\nRaw category strings and person names from the intent are resolved to canonical values using **Google Sheets mapping tables**.\n\nIf a value is unknown:\n\n1. GPT classifies it against the allowed list  \n2. The user is asked to confirm via a **Yes/No Telegram button**\n\nIf the user rejects the suggestion, an **inline button picker** is shown instead.\n\nEvery confirmed match is saved back to the mapping sheet so future queries resolve automatically.\n\n**The system learns over time.**\n\n---\n\n## \ud83d\udfe7 Query Engine (Layer 4)\n\nThe fully resolved intent is assembled into one clean **query descriptor**.\n\nAll expense rows and the category reference list are loaded from **Google Sheets in parallel** and merged into a single data object.\n\n---\n\n## \ud83d\udfe5 Analytics & Aggregation (Layer 5)\n\nA single **Code Node** applies all filters:\n\n- date range  \n- relative time references (last month, this week, etc.)  \n- person  \n- category  \n- `common_only` flag  \n\nIt then computes:\n\n- **total amount**\n- **transaction count**\n- optional **breakdowns**\n\nBreakdowns are calculated depending on the `group_by` field:\n\n- by **category**\n- by **person**\n\n---\n\n## \ud83d\udfea Response (Layer 6)\n\nThe result is formatted into a clean **Markdown message** including:\n\n- period  \n- applied filters  \n- total  \n- count  \n- optional breakdowns with percentage shares  \n\nThe reply is sent directly back to the user's **Telegram chat** as a thread reply to the original message."
      },
      "typeVersion": 1
    },
    {
      "id": "14b52ff0-e0db-42be-8f44-066315ba7642",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -16,
        0
      ],
      "parameters": {
        "color": 7,
        "width": 1024,
        "height": 2576,
        "content": "# HOW IT WORKS \u2014 AI TELEGRAM EXPENSE TRACKER (QUERY MODE)\n\nThis workflow answers natural language expense questions from Telegram using AI.\n\nInstead of opening spreadsheets, simply send a message like:\n\n- *How much did I spend on food last month?*\n- *What did we spend together this week?*\n- *Show me a breakdown by category for January.*\n\nThe workflow understands your question, resolves ambiguous names or categories, queries your expense data, and replies with a formatted summary \u2014 automatically.\n\n\n\n# \ud83d\udccb WHAT YOU NEED\n\nFour **Google Sheets** are required before using this workflow.\n\n## \ud83d\udccb WHAT YOU NEED\n\nFive Google Sheets are required before using this workflow.\n\n---\n\n**expenses**\n\nColumns:\n- date (date of expense)\n- amount (amount of expense)\n- category (expense category - e.g. supermarket)\n- description (description of the expense)\n- common_expense (true if it was an expense for all people - e.g. in a household)\n- Person - (Who made the expense)\n\nPurpose:\nMain expense table containing all recorded transactions.\n\n---\n\n**expense_categories**\n\nColumns:\n- category\n- description\n- examples\n\nPurpose:\nReference list of allowed expense categories used for classification.\n\n---\n\n**categories_mapping**\n\nColumns:\n- find\n- replace\n\nPurpose:\nMaps user aliases to canonical categories.\n\nExample:\n- sushi \u2192 food\n\n---\n\n**person_mapping**\n\nColumns:\n- find\n- replace\n\nPurpose:\nMaps nicknames or aliases to canonical person names.\n\nExample:\n- Johnny -> John\n\n---\n\n**list_persons**\n\nColumns:\n- person\n- description\n\nPurpose:\nReference list of all persons that can appear in the expense data.\n\n---\n\n# \ud83d\udca1 KEY FEATURES\n\n- Natural language queries in **German & English**\n- AI-powered intent parsing with **GPT-4.1-nano**\n- **Self-learning category & person resolution system**\n- Interactive disambiguation via **Telegram inline buttons**\n- Relative date support: this week, last month, this year\n- Group-by breakdowns: by category or by person\n- `common_only` filter for shared expense queries\n\n---\n\n# \u2b50 MULTI-USER SETUP\n\nThis workflow supports **multiple users simultaneously**.\n\nAdd all approved **Chat IDs** in the node:\n\nIF | User Authorized?\n\nEach expense query is automatically scoped to the sender unless:\n\n- a specific **person** is mentioned\n- or **\"we\"** is used.\n\nShared expenses are identified by the column:\n\ncommon_expense (boolean true / false)\n\nQueries like:\n\n*\"what did we spend together?\"*\n\nautomatically filter for **shared entries only**."
      },
      "typeVersion": 1
    }
  ],
  "active": false,
  "settings": {
    "executionOrder": "v1"
  },
  "versionId": "",
  "connections": {
    "GS | Load Expenses": {
      "main": [
        [
          {
            "node": "MERGE | Combine Expenses + Categories",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "IF | Person Known?": {
      "main": [
        [
          {
            "node": "MERGE | Combine Persons with List",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "MERGE | Loop Entry (Person)",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "LLM | Parse Intent": {
      "main": [
        [
          {
            "node": "JS | Extract Intent JSON",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "GS | Load Categories": {
      "main": [
        [
          {
            "node": "MERGE | Combine Expenses + Categories",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "IF | Category Known?": {
      "main": [
        [
          {
            "node": "MERGE | Combine Categories with List",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "MERGE | Loop Entry (Category)",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "IF | Person Present?": {
      "main": [
        [
          {
            "node": "MERGE | Combine Person Mapping",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "SPLIT | Split Persons",
            "type": "main",
            "index": 0
          },
          {
            "node": "GS | Read Person Mapping",
            "type": "main",
            "index": 0
          },
          {
            "node": "GS | Read Allowed Persons",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "IF | User Authorized?": {
      "main": [
        [
          {
            "node": "LLM | Parse Intent",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "LLM | Classify Person": {
      "main": [
        [
          {
            "node": "SET | Extract LLM Person",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "SPLIT | Split Persons": {
      "main": [
        [
          {
            "node": "MERGE | Join Persons with Mapping",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "IF | Category Present?": {
      "main": [
        [
          {
            "node": "MERGE | Combine Category + Person",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "SPLIT | Split Categories",
            "type": "main",
            "index": 0
          },
          {
            "node": "GS | Read Category Mapping",
            "type": "main",
            "index": 0
          },
          {
            "node": "GS | Read Allowed Categories",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "LOOP | Iterate Persons": {
      "main": [
        [
          {
            "node": "MERGE | Loop Entry (Person)",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "LLM | Classify Person",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "MSG | Telegram Inbound": {
      "main": [
        [
          {
            "node": "IF | Message or Callback?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "SET | Normalize Person": {
      "main": [
        [
          {
            "node": "MERGE | Check Person against Allowed",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "JS | Filter & Aggregate": {
      "main": [
        [
          {
            "node": "JS | Format Response Message",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "LLM | Classify Category": {
      "main": [
        [
          {
            "node": "SET | Extract LLM Category",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "GS | Read Person Mapping": {
      "main": [
        [
          {
            "node": "MERGE | Join Persons with Mapping",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "GS | Save Person Mapping": {
      "main": [
        [
          {
            "node": "LOOP | Iterate Persons",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "JS | Extract Intent JSON": {
      "main": [
        [
          {
            "node": "IF | Category Present?",
            "type": "main",
            "index": 0
          },
          {
            "node": "IF | Person Present?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "JS | Merge Intent Fields": {
      "main": [
        [
          {
            "node": "SET | Assemble Resolved Intent",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "SET | Extract LLM Person": {
      "main": [
        [
          {
            "node": "TG | Confirm Person Suggestion",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "SET | Normalize Category": {
      "main": [
        [
          {
            "node": "MERGE | Check Category against Allowed",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "SPLIT | Split Categories": {
      "main": [
        [
          {
            "node": "MERGE | Join Categories with Mapping",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "GS | Read Allowed Persons": {
      "main": [
        [
          {
            "node": "MERGE | Check Person against Allowed",
            "type": "main",
            "index": 1
          },
          {
            "node": "AGG | Aggregate Person List",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "IF | Message or Callback?": {
      "main": [
        [
          {
            "node": "IF | User Authorized?",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "IF | Category or Person Callback?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "JS | Read Person Callback": {
      "main": [
        [
          {
            "node": "HTTP | Forward Person Selection",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "LOOP | Iterate Categories": {
      "main": [
        [
          {
            "node": "MERGE | Loop Entry (Category)",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "LLM | Classify Category",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "SET | Set Resolved Person": {
      "main": [
        [
          {
            "node": "MERGE | Combine Person Mapping",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "GS | Read Category Mapping": {
      "main": [
        [
          {
            "node": "MERGE | Join Categories with Mapping",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "GS | Save Category Mapping": {
      "main": [
        [
          {
            "node": "LOOP | Iterate Categories",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "SET | Extract LLM Category": {
      "main": [
        [
          {
            "node": "TG | Confirm Category Suggestion",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "AGG | Aggregate Person List": {
      "main": [
        [
          {
            "node": "MERGE | Combine Persons with List",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "JS | Read Category Callback": {
      "main": [
        [
          {
            "node": "HTTP | Forward Category Selection",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "MERGE | Loop Entry (Person)": {
      "main": [
        [
          {
            "node": "SET | Set Resolved Person",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "SET | Set Resolved Category": {
      "main": [
        [
          {
            "node": "AGG | Aggregate Resolved Categories",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "GS | Read Allowed Categories": {
      "main": [
        [
          {
            "node": "MERGE | Check Category against Allowed",
            "type": "main",
            "index": 1
          },
          {
            "node": "AGG | Aggregate Category List",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "JS | Format Response Message": {
      "main": [
        [
          {
            "node": "TG | Send Reply",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "AGG | Aggregate Category List": {
      "main": [
        [
          {
            "node": "MERGE | Combine Categories with List",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "MERGE | Loop Entry (Category)": {
      "main": [
        [
          {
            "node": "SET | Set Resolved Category",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "MERGE | Combine Person Mapping": {
      "main": [
        [
          {
            "node": "MERGE | Combine Category + Person",
            "type": "main",
            "index": 2
          }
        ]
      ]
    },
    "SET | Assemble Resolved Intent": {
      "main": [
        [
          {
            "node": "GS | Load Expenses",
            "type": "main",
            "index": 0
          },
          {
            "node": "GS | Load Categories",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "SET | Set New+Old Person (LLM)": {
      "main": [
        [
          {
            "node": "MERGE | Combine Person Mapping Entries",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "TG | Confirm Person Suggestion": {
      "main": [
        [
          {
            "node": "IF | Person Suggestion Accepted?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "HTTP | Forward Person Selection": {
      "main": [
        [
          {
            "node": "TG | Confirm Person Selection",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "SET | Read Callback Body (Cat.)": {
      "main": [
        [
          {
            "node": "SET | Set New+Old Category (Selection)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "IF | Person Suggestion Accepted?": {
      "main": [
        [
          {
            "node": "SET | Set New+Old Person (LLM)",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "JS | Build Person Inline Buttons",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "JS | Build Person Inline Buttons": {
      "main": [
        [
          {
            "node": "HTTP | Send Person Selection Message",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "SET | Set New+Old Category (LLM)": {
      "main": [
        [
          {
            "node": "MERGE | Combine Category Mapping Entries",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "TG | Confirm Category Suggestion": {
      "main": [
        [
          {
            "node": "IF | Category Suggestion Accepted?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "WAIT | Wait for Person Selection": {
      "main": [
        [
          {
            "node": "SET | Read Callback Body (Person)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "HTTP | Forward Category Selection": {
      "main": [
        [
          {
            "node": "TG | Confirm Category Selection",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "IF | Category or Person Callback?": {
      "main": [
        [
          {
            "node": "JS | Read Category Callback",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "JS | Read Person Callback",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "MERGE | Combine Category + Person": {
      "main": [
        [
          {
            "node": "JS | Merge Intent Fields",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "MERGE | Combine Persons with List": {
      "main": [
        [
          {
            "node": "LOOP | Iterate Persons",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "MERGE | Join Persons with Mapping": {
      "main": [
        [
          {
            "node": "SET | Normalize Person",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "SET | Read Callback Body (Person)": {
      "main": [
        [
          {
            "node": "SET | Set New+Old Person (Selection)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "IF | Category Suggestion Accepted?": {
      "main": [
        [
          {
            "node": "SET | Set New+Old Category (LLM)",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "JS | Build Category Inline Buttons",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "JS | Build Category Inline Buttons": {
      "main": [
        [
          {
            "node": "HTTP | Send Category Selection Message",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "WAIT | Wait for Category Selection": {
      "main": [
        [
          {
            "node": "SET | Read Callback Body (Cat.)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "AGG | Aggregate Resolved Categories": {
      "main": [
        [
          {
            "node": "MERGE | Combine Category + Person",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "HTTP | Send Person Selection Message": {
      "main": [
        [
          {
            "node": "WAIT | Wait for Person Selection",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "MERGE | Check Person against Allowed": {
      "main": [
        [
          {
            "node": "IF | Person Known?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "MERGE | Combine Categories with List": {
      "main": [
        [
          {
            "node": "LOOP | Iterate Categories",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "MERGE | Join Categories with Mapping": {
      "main": [
        [
          {
            "node": "SET | Normalize Category",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "SET | Set New+Old Person (Selection)": {
      "main": [
        [
          {
            "node": "MERGE | Combine Person Mapping Entries",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "MERGE | Combine Expenses + Categories": {
      "main": [
        [
          {
            "node": "JS | Filter & Aggregate",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "HTTP | Send Category Selection Message": {
      "main": [
        [
          {
            "node": "WAIT | Wait for Category Selection",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "MERGE | Check Category against Allowed": {
      "main": [
        [
          {
            "node": "IF | Category Known?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "MERGE | Combine Person Mapping Entries": {
      "main": [
        [
          {
            "node": "GS | Save Person Mapping",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "SET | Set New+Old Category (Selection)": {
      "main": [
        [
          {
            "node": "MERGE | Combine Category Mapping Entries",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "MERGE | Combine Category Mapping Entries": {
      "main": [
        [
          {
            "node": "GS | Save Category Mapping",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}