AutomationFlowsAI & RAG › Bundestag: Plenarprotokoll Parsen Und Mit Ollama Zusammenfassen

Bundestag: Plenarprotokoll Parsen Und Mit Ollama Zusammenfassen

Bundestag: Plenarprotokoll parsen und mit Ollama zusammenfassen. Uses httpRequest, chainLlm, readWriteFile, lmChatOllama. Event-driven trigger; 27 nodes.

Event trigger★★★★☆ complexityAI-powered27 nodesHTTP RequestChain LlmRead Write FileOllama Chat
AI & RAG Trigger: Event Nodes: 27 Complexity: ★★★★☆ AI nodes: yes Added:

This workflow follows the Chainllm → HTTP Request recipe pattern — see all workflows that pair these two integrations.

The workflow JSON

Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →

Download .json
{
  "name": "Bundestag: Plenarprotokoll parsen und mit Ollama zusammenfassen",
  "nodes": [
    {
      "parameters": {},
      "id": "trigger-manual",
      "name": "Manueller Start",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        -1100,
        -60
      ]
    },
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "days",
              "triggerAtHour": 6,
              "triggerAtMinute": 0
            }
          ]
        }
      },
      "id": "trigger-schedule",
      "name": "T\u00e4glich 06:00",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        -1100,
        120
      ]
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "c-basisurl",
              "name": "dipBaseUrl",
              "value": "https://search.dip.bundestag.de/api/v1",
              "type": "string"
            },
            {
              "id": "c-tage",
              "name": "tageZurueck",
              "value": 45,
              "type": "number"
            },
            {
              "id": "c-modell",
              "name": "modell",
              "value": "qwen3:14b",
              "type": "string"
            },
            {
              "id": "c-keepalive",
              "name": "keepAlive",
              "value": "30m",
              "type": "string"
            },
            {
              "id": "c-verzeichnis",
              "name": "berichtsverzeichnis",
              "value": "/data/protokolle",
              "type": "string"
            },
            {
              "id": "c-webhook",
              "name": "webhookUrl",
              "value": "",
              "type": "string"
            },
            {
              "id": "c-minwoerter",
              "name": "minWoerter",
              "value": 120,
              "type": "number"
            },
            {
              "id": "c-buendeln",
              "name": "buendelnAbMedian",
              "value": 200,
              "type": "number"
            },
            {
              "id": "c-anlagen",
              "name": "anlagenAuswerten",
              "value": true,
              "type": "boolean"
            },
            {
              "id": "c-nurtops",
              "name": "nurTops",
              "value": 0,
              "type": "number"
            },
            {
              "id": "c-maxrede",
              "name": "maxZeichenRede",
              "value": 12000,
              "type": "number"
            },
            {
              "id": "c-maxkurz",
              "name": "maxZeichenKurzbeitrag",
              "value": 1200,
              "type": "number"
            },
            {
              "id": "c-maxreduce",
              "name": "maxZeichenReduce",
              "value": 24000,
              "type": "number"
            },
            {
              "id": "c-erneut",
              "name": "erneutVerarbeiten",
              "value": false,
              "type": "boolean"
            }
          ]
        },
        "options": {}
      },
      "id": "konfiguration",
      "name": "Konfiguration",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        -880,
        20
      ],
      "notes": "Alle Stellschrauben an einem Ort. nurTops=1 f\u00fcr einen schnellen Testlauf, erneutVerarbeiten=true um die Deduplizierung zu \u00fcbergehen."
    },
    {
      "parameters": {
        "url": "={{ $json.dipBaseUrl }}/plenarprotokoll",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "f.zuordnung",
              "value": "BT"
            },
            {
              "name": "f.datum.start",
              "value": "={{ new Date(Date.now() - $json.tageZurueck * 86400000).toISOString().slice(0, 10) }}"
            },
            {
              "name": "format",
              "value": "json"
            }
          ]
        },
        "options": {
          "timeout": 60000
        }
      },
      "id": "liste-holen",
      "name": "Protokollliste holen",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        -660,
        20
      ],
      "notes": "Nur Metadaten, ohne Volltext. Credential: Header Auth mit Name 'Authorization' und Wert 'ApiKey <DIP_API_KEY>'."
    },
    {
      "parameters": {
        "jsCode": "// W\u00e4hlt aus der DIP-Trefferliste das neueste Bundestagsprotokoll und pr\u00fcft,\n// ob es seit dem letzten Lauf neu oder \u00fcberarbeitet wurde.\n//\n// DIP korrigiert Protokolle noch Tage nach der Sitzung, deshalb reicht die id\n// als Merkmal nicht - `aktualisiert` geh\u00f6rt dazu.\n\nconst konfig = $('Konfiguration').first().json;\nconst status = $getWorkflowStaticData('global');\n\nconst dokumente = $input\n  .all()\n  .flatMap((item) => item.json.documents || [])\n  .filter((d) => d.herausgeber === 'BT')\n  .sort((a, b) => String(b.datum).localeCompare(String(a.datum)));\n\nif (!dokumente.length) {\n  return [{ json: { neu: false, grund: 'Im Abfragezeitraum liegt kein Bundestagsprotokoll vor.' } }];\n}\n\nconst neuestes = dokumente[0];\nconst kennung = `${neuestes.id}@${neuestes.aktualisiert || ''}`;\n\nif (!konfig.erneutVerarbeiten && status.letzteKennung === kennung) {\n  return [\n    {\n      json: {\n        neu: false,\n        grund: `Protokoll ${neuestes.dokumentnummer} vom ${neuestes.datum} wurde bereits verarbeitet.`,\n        id: neuestes.id,\n      },\n    },\n  ];\n}\n\nreturn [\n  {\n    json: {\n      neu: true,\n      kennung,\n      id: neuestes.id,\n      dokumentnummer: neuestes.dokumentnummer,\n      datum: neuestes.datum,\n      aktualisiert: neuestes.aktualisiert,\n      titel: neuestes.titel,\n    },\n  },\n];\n"
      },
      "id": "neuestes-waehlen",
      "name": "Neuestes Protokoll w\u00e4hlen",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -440,
        20
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "ist-neu",
              "leftValue": "={{ $json.neu }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "if-neu",
      "name": "Neues Protokoll?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        -220,
        20
      ]
    },
    {
      "parameters": {},
      "id": "nichts-neues",
      "name": "Nichts Neues",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        0,
        180
      ],
      "notes": "Der Bundestag tagt an rund 20 Wochen im Jahr - an den meisten Tagen endet der Lauf korrekt hier."
    },
    {
      "parameters": {
        "url": "={{ $('Konfiguration').first().json.dipBaseUrl }}/plenarprotokoll-text/{{ $json.id }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "format",
              "value": "json"
            }
          ]
        },
        "options": {
          "timeout": 180000
        }
      },
      "id": "volltext-holen",
      "name": "Volltext holen",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        0,
        -60
      ],
      "notes": "0,4 bis 0,9 MB Text pro Sitzungstag."
    },
    {
      "parameters": {
        "jsCode": "// Zerlegt den Volltext des Protokolls deterministisch.\n//\n// Der Parser ist die einzige Wahrheit f\u00fcr Struktur, Beschlusspassagen und\n// Abstimmungszahlen. Er liegt als lib/parse-protokoll.js im Repository, ist\n// dort getestet und wird von scripts/build-workflow.mjs hier eingesetzt.\n\n// ==========================================================================\n// Eingebettet aus lib/parse-protokoll.js - NICHT hier bearbeiten.\n// Quelle im Repository \u00e4ndern und \"node scripts/build-workflow.mjs\" laufen lassen.\n// ==========================================================================\n/**\n * parse-protokoll.js\n *\n * Zerlegt ein Plenarprotokoll des Deutschen Bundestages, wie es die DIP-API\n * unter /plenarprotokoll-text im Feld `text` liefert, deterministisch in\n * Tagesordnungspunkte, Redebeitr\u00e4ge, Beschlusspassagen und ausgez\u00e4hlte\n * namentliche Abstimmungen.\n *\n * Bewusst ohne Abh\u00e4ngigkeiten und ohne Node-spezifische APIs, damit die Datei\n * unver\u00e4ndert in einen n8n-Code-Node injiziert werden kann\n * (siehe scripts/build-workflow.mjs).\n *\n * Alle Muster sind gegen echte Protokolle der 21. Wahlperiode (21/83 - 21/90)\n * entwickelt. Die Reihenfolge der Schritte in parseProtokoll() ist zwingend:\n * ohne die Normalisierung in Schritt 1 greift keines der folgenden Muster.\n */\n\n// ---------------------------------------------------------------------------\n// Fraktionen\n// ---------------------------------------------------------------------------\n\n/** Kanonische Fraktionsnamen. */\nconst FRAKTIONEN = {\n  CDU_CSU: 'CDU/CSU',\n  SPD: 'SPD',\n  AFD: 'AfD',\n  GRUENE: 'B\u00dcNDNIS 90/DIE GR\u00dcNEN',\n  LINKE: 'DIE LINKE',\n  FDP: 'FDP',\n  BSW: 'BSW',\n  SSW: 'SSW',\n  FRAKTIONSLOS: 'fraktionslos',\n  REGIERUNG: 'Regierung',\n  PRAESIDIUM: 'Pr\u00e4sidium',\n};\n\n/**\n * Schreibvarianten, die in echten Protokollen vorkommen - inklusive der\n * Kleinschreibung aus der Pr\u00e4sidiums-Prosa (\"mit den Stimmen von B\u00fcndnis 90/\n * Die Gr\u00fcnen und Die Linke\") und des Leerzeichens nach dem Schr\u00e4gstrich, das\n * in den Namenslisten der Anlagen auftaucht.\n */\nconst FRAKTION_VARIANTEN = [\n  [/^cdu\\/csu$|^cdu$|^csu$|^cdu\\/csu-fraktion$|^unionsfraktion$/i, FRAKTIONEN.CDU_CSU],\n  [/^spd$|^spd-fraktion$/i, FRAKTIONEN.SPD],\n  [/^afd$|^afd-fraktion$|^alternative f\u00fcr deutschland$/i, FRAKTIONEN.AFD],\n  [/^b(\u00fc|ue)ndnis\\s*90\\s*\\/\\s*die\\s*gr(\u00fc|ue)nen$|^gr(\u00fc|ue)ne[nr]?$|^die\\s*gr(\u00fc|ue)nen$/i, FRAKTIONEN.GRUENE],\n  [/^die\\s*linke$|^linke[n]?$|^der\\s*linken$|^die\\s*linke-fraktion$/i, FRAKTIONEN.LINKE],\n  [/^f\\.?d\\.?p\\.?$/i, FRAKTIONEN.FDP],\n  [/^bsw$/i, FRAKTIONEN.BSW],\n  [/^ssw$/i, FRAKTIONEN.SSW],\n  [/^fraktionslos$|^fraktionslose[rn]?$/i, FRAKTIONEN.FRAKTIONSLOS],\n];\n\n/** Alternative f\u00fcr die Sprecherzeile - toleriert NBSP-Reste und Leerzeichen. */\nconst FRAKTION_ALTERNATIVE =\n  'CDU/CSU|SPD|AfD|B\u00dcNDNIS\\\\s?90/\\\\s?DIE GR\u00dcNEN|B\u00fcndnis\\\\s?90/\\\\s?Die Gr\u00fcnen|' +\n  'DIE LINKE|Die Linke|FDP|F\\\\.D\\\\.P\\\\.|BSW|SSW|fraktionslos';\n\n/**\n * Rollen der Regierungsbank. Aus den Protokollen 21/83-21/90 erhoben; die\n * Ressortbezeichnung selbst ist frei (\"Bundesministerin f\u00fcr Gesundheit\",\n * \"Parl. Staatssekret\u00e4r beim Bundesminister des Innern\", ...).\n */\nconst REGIERUNGS_ROLLE =\n  'Bundeskanzler(?:in)?|Bundesminister(?:in)?|Staatsminister(?:in)?|' +\n  'Parl\\\\.?\\\\s?Staatssekret\u00e4r(?:in)?|Staatssekret\u00e4r(?:in)?|' +\n  'Beauftragte[rn]?\\\\s+der\\\\s+Bundesregierung';\n\n// ---------------------------------------------------------------------------\n// Sprecherzeilen\n// ---------------------------------------------------------------------------\n\n/**\n * Pr\u00e4sidium: \"Pr\u00e4sidentin Julia Kl\u00f6ckner:\", \"Vizepr\u00e4sident Bodo Ramelow:\".\n * Das kleine \"p\" nach \"Vize\" ist der Grund, warum ein naives\n * /(?:Vize)?Pr\u00e4sident/ in zwei von acht Protokollen s\u00e4mtliche\n * Pr\u00e4sidiumsbeitr\u00e4ge verfehlt.\n */\nconst RE_PRAESIDIUM = /^((?:Alters|Vize)?[Pp]r\u00e4sident(?:in)?)\\s+([^\\n:]{2,60}):$/;\n\n/** Regierungsbank: \"Nina Warken, Bundesministerin f\u00fcr Gesundheit:\". */\nconst RE_REGIERUNG = new RegExp(\n  '^([^\\\\n,]{2,60}),\\\\s((?:' + REGIERUNGS_ROLLE + ')[^:]{0,120}):$'\n);\n\n/**\n * Fraktion: \"Claudia Weiss (AfD):\" - mit optionalem Wahlkreiszusatz davor,\n * etwa \"Dagmar Schmidt (Wetzlar) (SPD):\" oder\n * \"Stephan Mayer (Alt\u00f6tting) (CDU/CSU):\".\n */\nconst RE_FRAKTION = new RegExp(\n  '^([^\\\\n(]{2,60}?)(?:\\\\s\\\\(([^)]{2,30})\\\\))?\\\\s\\\\((' + FRAKTION_ALTERNATIVE + ')\\\\):$'\n);\n\n/**\n * Ab wie vielen W\u00f6rtern ein Pr\u00e4sidiumsblock nicht mehr als blosse Regie gilt.\n * Darunter fallen \"Erlauben Sie eine Zwischenfrage?\", \"Setzen Sie fort.\",\n * \"Kommen Sie bitte zum Schluss.\" - alles, was eine Rede unterbricht, ohne\n * sie zu beenden.\n */\nconst REGIE_MAX_WOERTER = 60;\n\n// ---------------------------------------------------------------------------\n// Struktur- und Inhaltsmuster\n// ---------------------------------------------------------------------------\n\n/** Beginn der Anlagen - in allen gepr\u00fcften Protokollen vorhanden. */\nconst MARKER_ANLAGEN = 'Anlagen zum Stenografischen Bericht';\n\n/**\n * Aufruf eines Tagesordnungs- oder Zusatzpunktes. Im Flie\u00dftext gibt es keine\n * freistehenden TOP-\u00dcberschriften; sie existieren nur im Inhaltsverzeichnis.\n */\nconst RE_TOP_AUFRUF =\n  /(?:Ich rufe|Wir kommen)\\s+(?:nun\\s+|jetzt\\s+|auch\\s+)?(?:auf\\s+)?(?:zu[rm]?\\s+)?(?:den|die|das)?\\s*(Tagesordnungspunkt|Zusatzpunkt)e?\\s+(\\d+)\\s*([a-z])?/;\n\n/** Drucksachennummern, z. B. \"Drucksachen 21/6130, 21/6559, 21/7016\". */\nconst RE_DRUCKSACHEN = /Drucksachen?\\s+((?:\\d{1,2}\\/\\d+(?:\\s+Buchstabe\\s+\\w)?[,;]?\\s*)+)/g;\nconst RE_DRUCKSACHE_NR = /\\d{1,2}\\/\\d+/g;\n\n/** Unterpunkte \"a)\", \"b)\" ... am Zeilenanfang. */\nconst RE_UNTERPUNKT = /^([a-z])\\)\\s*(.*)$/;\n\n/**\n * Wortgrenzen f\u00fcr deutsche W\u00f6rter. `\\b` ist in JavaScript ASCII-basiert:\n * vor \"\u00dcberweisung\" steht damit nie eine Grenze, weil weder das Leerzeichen\n * davor noch das \"\u00dc\" als Wortzeichen gilt. /\\b\u00dcberweisung\\b/ matcht deshalb\n * nichts - ein Fehler, der genau die \u00dcberweisungsbeschl\u00fcsse verschluckt.\n */\nconst WB = '(?<![A-Za-z\u00c4\u00d6\u00dc\u00e4\u00f6\u00fc\u00df])';\nconst WE = '(?![A-Za-z\u00c4\u00d6\u00dc\u00e4\u00f6\u00fc\u00df])';\n\n/**\n * Ergebnis-S\u00e4tze des Pr\u00e4sidiums. Erst nach dem Entfernen der Namenslisten\n * anwenden, sonst matcht auch der zitierte Antragstext.\n */\nconst RE_BESCHLUSS = new RegExp(\n  WB +\n    '(?:angenommen|abgelehnt|\u00fcberwiesen|\u00dcberweisung|beschlossen|zur\u00fcckgezogen|f\u00fcr erledigt erkl\u00e4rt|einstimmig|Enthaltung(?:en)?|interfraktionell vereinbart)' +\n    WE,\n  'i'\n);\n\n/** Ein Satz ist nur dann ein Beschluss, wenn er auch ein Ergebnis feststellt. */\nconst RE_BESCHLUSS_ERGEBNIS = new RegExp(\n  [\n    WB + '(?:ist|sind|wird|werden|wurde|wurden|hat|haben)' + WE + '[^.]{0,160}' + WB + '(?:angenommen|abgelehnt|\u00fcberwiesen|zur\u00fcckgezogen|f\u00fcr erledigt erkl\u00e4rt|so beschlossen)' + WE,\n    WB + 'Damit ist' + WE,\n    WB + 'Hiermit ist' + WE,\n    WB + 'Mit Ja haben gestimmt' + WE,\n    WB + '\u00dcberweisung' + WE + '[^.]{0,120}' + WB + '(?:vorgeschlagen|beschlossen)' + WE,\n  ].join('|'),\n  'i'\n);\n\n/**\n * Das Pr\u00e4sidium sammelt Abstimmungsergebnisse und verliest sie parallel zu\n * einer ganz anderen Debatte. Diese R\u00fcckkehr-Formeln sagen, worauf sich die\n * folgenden Beschlusss\u00e4tze beziehen.\n */\nconst RE_RUECKKEHR = new RegExp(\n  '(?:komme zur\u00fcck|kommen zur\u00fcck|kehren wir zur\u00fcck|setzen die Abstimmung|setzen die Aussprache|unterbreche die Aussprache)[^.]{0,140}?' +\n    '(Tagesordnungspunkt|Zusatzpunkt)e?\\\\s+(\\\\d+)',\n  'i'\n);\n\n/** Ausl\u00f6ser f\u00fcr den Sub-Parser der namentlichen Abstimmung. */\nconst MARKER_NAMENTLICH = 'Endg\u00fcltiges Ergebnis';\n\n/** S\u00e4tze, die ein verlesenes Abstimmungsergebnis wiedergeben. */\nconst RE_STIMMENZAHLEN =\n  /Mit Ja haben gestimmt|Abgegebene Stimmkarten|Ergebnis der namentlichen Abstimmung/i;\n\n/** Abschnitts\u00fcberschriften innerhalb der Namensliste. */\nconst RE_ROSTER_ABSCHNITT = /^(Ja|Nein|Enthalten|Enthaltung(?:en)?|Ung\u00fcltig)$/;\n\n/**\n * TOP-Titel, die auf einen Frage-/Antwort-Block hindeuten. Die Aktuelle Stunde\n * geh\u00f6rt ausdr\u00fccklich nicht dazu: dort wird in normaler Redel\u00e4nge debattiert.\n */\nconst RE_BEFRAGUNGS_TOP = /Fragestunde|Befragung der Bundesregierung|Regierungsbefragung/i;\n\n/** Anlagen-\u00dcberschriften, die Redebeitr\u00e4ge enthalten. */\nconst RE_ANLAGE_KOPF = /^Anlage\\s+(\\d+)\\s*$/;\nconst RE_ANLAGE_ZU_PROTOKOLL = /Zu Protokoll gegebene Reden/i;\nconst RE_ANLAGE_31GO = /Erkl\u00e4rung(?:en)?\\s+nach\\s+\u00a7\\s?31\\s*(?:Absatz\\s?\\d(?:\\s+und\\s+\\d)?\\s*)?GO/i;\nconst RE_ANLAGE_BEZUG = /^\\((Tagesordnungspunkt|Zusatzpunkt)\\s*(\\d+)\\s*([a-z])?\\)$/;\n\n// ---------------------------------------------------------------------------\n// Hilfsfunktionen\n// ---------------------------------------------------------------------------\n\n/**\n * Schritt 1 der Pipeline. Der DIP-Volltext ist voller gesch\u00fctzter\n * Leerzeichen - sie stehen mitten in \"B\u00dcNDNIS 90/DIE GR\u00dcNEN\", \"\u00a7 31\" und\n * \"21/7016 \u2013\". Ohne diese Normalisierung greift keines der Muster.\n * Ausserdem kleben Fussnotenmarker im Fliesstext:\n * \"...bekannt geben.1Ergebnis Seite 11159 C\".\n */\nfunction normalisiereText(text) {\n  return String(text || '')\n    .replace(/\\r\\n?/g, '\\n')\n    .replace(/[\u00a0\u2007\u202f\u2009\u200a]/g, ' ')\n    .replace(/[\u2011\u2012\u2013\u2014]/g, (m) => (m === '\u2011' ? '-' : '\u2013'))\n    .replace(/(\\S)\\d+Ergebnis\\s+Seite\\s+\\d+\\s*[A-D]?/g, '$1')\n    .replace(/(\\S)\\d+Anlage\\s+\\d+/g, '$1')\n    .replace(/[ \\t]+\\n/g, '\\n');\n}\n\n/** F\u00fchrt eine beliebige Schreibvariante auf den kanonischen Namen zur\u00fcck. */\nfunction kanonischeFraktion(roh) {\n  if (!roh) return null;\n  const s = String(roh).replace(/\\s+/g, ' ').trim().replace(/[.,;:]$/, '');\n  for (const [muster, name] of FRAKTION_VARIANTEN) {\n    if (muster.test(s)) return name;\n  }\n  return null;\n}\n\n/** Z\u00e4hlt W\u00f6rter - Grundlage f\u00fcr `kurz` und die Tagestyp-Erkennung. */\nfunction zaehleWoerter(text) {\n  const treffer = String(text || '').match(/\\S+/g);\n  return treffer ? treffer.length : 0;\n}\n\nfunction median(zahlen) {\n  if (!zahlen.length) return 0;\n  const s = [...zahlen].sort((a, b) => a - b);\n  const m = Math.floor(s.length / 2);\n  return s.length % 2 ? s[m] : Math.round((s[m - 1] + s[m]) / 2);\n}\n\n/**\n * Erkennt eine Sprecherzeile. Die Muster sind vollst\u00e4ndig verankert (^...$),\n * denn pro Protokoll enden rund 40 Zeilen mitten in einer Rede auf einen\n * Doppelpunkt (\"Und zwar wie folgt:\", \"... sagte \u2013 Zitat \u2013:\").\n */\nfunction erkenneSprecher(zeile) {\n  const l = zeile.trim();\n  if (l.length < 5 || l.length > 180 || !l.endsWith(':')) return null;\n\n  let m = RE_PRAESIDIUM.exec(l);\n  if (m) {\n    return {\n      rolle: 'Pr\u00e4sidium',\n      name: m[2].trim(),\n      funktion: m[1],\n      fraktion: FRAKTIONEN.PRAESIDIUM,\n    };\n  }\n\n  m = RE_REGIERUNG.exec(l);\n  if (m) {\n    return {\n      rolle: 'Regierung',\n      name: m[1].trim(),\n      funktion: m[2].trim(),\n      fraktion: FRAKTIONEN.REGIERUNG,\n    };\n  }\n\n  m = RE_FRAKTION.exec(l);\n  if (m) {\n    const fraktion = kanonischeFraktion(m[3]);\n    if (!fraktion) return null;\n    return {\n      rolle: 'MdB',\n      name: m[1].trim(),\n      wahlkreis: m[2] ? m[2].trim() : null,\n      funktion: null,\n      fraktion,\n    };\n  }\n\n  return null;\n}\n\n/**\n * Zerlegt einen Zwischenruf in Typ und beteiligte Fraktionen.\n * \"(Beifall bei der AfD)\", \"(J\u00f6rn K\u00f6nig [AfD]: Und die w\u00e4hlen uns!)\",\n * \"(Zuruf der Abg. Lisa Badum [B\u00dcNDNIS 90/DIE GR\u00dcNEN])\".\n */\nfunction analysiereZwischenruf(roh) {\n  const inhalt = roh.replace(/^\\(/, '').replace(/\\)$/, '').trim();\n\n  let typ = 'sonstiges';\n  if (/^Beifall|^Anhaltender Beifall|^Lebhafter Beifall/i.test(inhalt)) typ = 'beifall';\n  else if (/^Zuruf|^Zurufe|^Gegenruf/i.test(inhalt)) typ = 'zuruf';\n  else if (/^Lachen|^Heiterkeit/i.test(inhalt)) typ = 'lachen';\n  else if (/^Widerspruch|^Unruhe|^Protest/i.test(inhalt)) typ = 'widerspruch';\n  else if (/\\[[^\\]]+\\]\\s*:/.test(inhalt)) typ = 'zuruf';\n\n  const fraktionen = new Set();\n  // Namentliche Zurufe tragen die Fraktion in eckigen Klammern.\n  for (const m of inhalt.matchAll(/\\[([^\\]]{2,40})\\]/g)) {\n    const f = kanonischeFraktion(m[1]);\n    if (f) fraktionen.add(f);\n  }\n  // Kollektive Reaktionen: \"bei der AfD\", \"beim B\u00dcNDNIS 90/DIE GR\u00dcNEN\",\n  // \"bei Abgeordneten der SPD\", \"der Linken\".\n  for (const m of inhalt.matchAll(\n    /\\b(?:bei|beim|von|der|des|dem)\\s+(?:der\\s+|dem\\s+|den\\s+)?(?:Fraktion\\s+)?(?:Abgeordneten\\s+(?:der\\s+|des\\s+|dem\\s+)?)?(CDU\\/CSU|SPD|AfD|B\u00dcNDNIS\\s?90\\/\\s?DIE GR\u00dcNEN|B\u00fcndnis\\s?90\\/\\s?Die Gr\u00fcnen|Gr\u00fcnen|DIE LINKE|Die Linke|Linken|FDP|BSW|SSW)\\b/g\n  )) {\n    const f = kanonischeFraktion(m[1]);\n    if (f) fraktionen.add(f);\n  }\n\n  return { typ, text: inhalt, fraktionen: [...fraktionen] };\n}\n\n/**\n * Trennt Redetext und Zwischenrufe. Ein Zwischenruf ist jede allein stehende\n * Zeile, die komplett geklammert ist - das ist vollst\u00e4ndiger als eine\n * Stichwort-Whitelist (in 21/90 sind es 1134 Zeilen). Ausgenommen sind die\n * Strukturmarker \"(Tagesordnungspunkt 22a)\" / \"(Zusatzpunkt 28a)\".\n */\nfunction trenneZwischenrufe(zeilen) {\n  const text = [];\n  const interjektionen = [];\n  for (const zeile of zeilen) {\n    const l = zeile.trim();\n    if (l.length > 2 && l.startsWith('(') && l.endsWith(')') && !RE_ANLAGE_BEZUG.test(l)) {\n      interjektionen.push(analysiereZwischenruf(l));\n    } else if (l) {\n      text.push(l);\n    }\n  }\n  return { text: text.join('\\n'), interjektionen };\n}\n\n/** Zerlegt einen Absatz in S\u00e4tze - grob, aber ausreichend f\u00fcr Beschlusss\u00e4tze. */\nfunction inSaetze(absatz) {\n  return absatz\n    .split(/(?<=[.!?])\\s+(?=[A-Z\u00c4\u00d6\u00dc\u201e\u2013])/)\n    .map((s) => s.trim())\n    .filter(Boolean);\n}\n\n// ---------------------------------------------------------------------------\n// Namentliche Abstimmungen\n// ---------------------------------------------------------------------------\n\n/**\n * Parst die Namensliste einer namentlichen Abstimmung.\n *\n *   Endg\u00fcltiges Ergebnis\n *   Abgegebene Stimmen:\\t606;\n *   davon\n *   ja:\\t318\n *   nein:\\t284\n *   enthalten:\\t4\n *   Ja\n *   CDU/CSU\n *   <Name> ... <Name>\n *   SPD\n *   ...\n *   Nein\n *   ...\n *\n * Liefert die exakt ausgez\u00e4hlten Stimmen je Fraktion. Die Namen selbst werden\n * verworfen - sie sind f\u00fcr den Report wertlos und w\u00fcrden jeden LLM-Prompt\n * sprengen (eine einzige Liste umfasst \u00fcber 600 Zeilen).\n *\n * @returns {{ergebnis: object, endeIndex: number}|null}\n */\nfunction parseNamentlicheListe(zeilen, startIndex) {\n  const ergebnis = {\n    abgegeben: null,\n    ja: null,\n    nein: null,\n    enthalten: null,\n    fraktionen: {},\n  };\n\n  let i = startIndex + 1;\n  let abschnitt = null;\n  let fraktion = null;\n\n  for (; i < zeilen.length; i++) {\n    const l = zeilen[i].trim();\n    if (!l) continue;\n\n    // Eine echte Satzzeile beendet die Liste (in 21/90 ist das der Hinweis auf\n    // die entschuldigten Abgeordneten direkt hinter dem letzten Namen).\n    if (l.length > 100) break;\n    if (erkenneSprecher(l)) break;\n\n    let m = /^Abgegebene\\s+Stimmen:?\\s*(\\d+)/.exec(l);\n    if (m) { ergebnis.abgegeben = Number(m[1]); continue; }\n    m = /^ja:?\\s*(\\d+)/i.exec(l);\n    if (m && /^ja/i.test(l)) { ergebnis.ja = Number(m[1]); continue; }\n    m = /^nein:?\\s*(\\d+)/i.exec(l);\n    if (m) { ergebnis.nein = Number(m[1]); continue; }\n    m = /^enthalten:?\\s*(\\d+)/i.exec(l);\n    if (m) { ergebnis.enthalten = Number(m[1]); continue; }\n    if (/^davon$/i.test(l)) continue;\n\n    m = RE_ROSTER_ABSCHNITT.exec(l);\n    if (m) {\n      const k = m[1].toLowerCase();\n      abschnitt = k === 'ja' ? 'ja' : k === 'nein' ? 'nein' : k === 'ung\u00fcltig' ? 'ungueltig' : 'enthalten';\n      fraktion = null;\n      continue;\n    }\n\n    const alsFraktion = kanonischeFraktion(l);\n    if (alsFraktion) { fraktion = alsFraktion; continue; }\n\n    // Alles \u00dcbrige innerhalb eines Abschnitts ist ein Abgeordnetenname.\n    if (abschnitt && fraktion) {\n      if (!ergebnis.fraktionen[fraktion]) {\n        ergebnis.fraktionen[fraktion] = { ja: 0, nein: 0, enthalten: 0, ungueltig: 0 };\n      }\n      ergebnis.fraktionen[fraktion][abschnitt] += 1;\n    }\n  }\n\n  if (ergebnis.abgegeben === null && !Object.keys(ergebnis.fraktionen).length) return null;\n  return { ergebnis, endeIndex: i };\n}\n\n/**\n * Zieht aus einem Pr\u00e4sidiumsblock alle namentlichen Abstimmungen heraus und\n * gibt die um die Namenslisten bereinigten Zeilen zur\u00fcck.\n */\nfunction extrahiereNamentlicheAbstimmungen(zeilen) {\n  const abstimmungen = [];\n  const rest = [];\n\n  for (let i = 0; i < zeilen.length; i++) {\n    if (zeilen[i].trim() === MARKER_NAMENTLICH) {\n      const parsed = parseNamentlicheListe(zeilen, i);\n      if (parsed) {\n        // Der verlesene Satz steht unmittelbar davor und nennt Gegenstand und\n        // Ergebnis im Klartext.\n        // Der verlesene Satz steht unmittelbar davor und nennt Gegenstand und\n        // Ergebnis im Klartext; die abschliessende Feststellung (\"Damit ist\n        // der Gesetzentwurf angenommen\") kann auch erst hinter der\n        // Namensliste stehen.\n        const davor = rest.slice(-5).join(' ');\n        const danach = zeilen.slice(parsed.endeIndex, parsed.endeIndex + 3).join(' ');\n        const verlesen = inSaetze(davor).filter((s) =>\n          /Stimmkarten|haben gestimmt|Enthaltungen gab es|Ergebnis der namentlichen|Damit ist/i.test(s)\n        );\n        const umfeld = davor + ' ' + danach;\n\n        const stimmen = parsed.ergebnis;\n        if (stimmen.abgegeben !== null) {\n          if (stimmen.ja === null) stimmen.ja = 0;\n          if (stimmen.nein === null) stimmen.nein = 0;\n          if (stimmen.enthalten === null) stimmen.enthalten = 0;\n        }\n\n        let ergebnisText = null;\n        let ergebnisQuelle = null;\n        if (/(?:Damit|Hiermit) ist[^.]{0,120}angenommen/i.test(umfeld)) {\n          ergebnisText = 'angenommen';\n          ergebnisQuelle = 'verlesen';\n        } else if (/(?:Damit|Hiermit) ist[^.]{0,120}abgelehnt/i.test(umfeld)) {\n          ergebnisText = 'abgelehnt';\n          ergebnisQuelle = 'verlesen';\n        } else if (stimmen.ja !== null && stimmen.nein !== null) {\n          // Einfache Mehrheit. Bei Vorlagen mit qualifizierter Mehrheit\n          // (Grundgesetz\u00e4nderungen) kann das abweichen - deshalb die Quelle.\n          ergebnisText = stimmen.ja > stimmen.nein ? 'angenommen' : 'abgelehnt';\n          ergebnisQuelle = 'berechnet';\n        }\n\n        abstimmungen.push({\n          ...stimmen,\n          verlesen: verlesen.join(' ').trim() || null,\n          drucksachen: [...new Set([...umfeld.matchAll(RE_DRUCKSACHE_NR)].map((m) => m[0]))],\n          ergebnisText,\n          ergebnisQuelle,\n        });\n        i = parsed.endeIndex - 1;\n        continue;\n      }\n    }\n    rest.push(zeilen[i]);\n  }\n\n  return { abstimmungen, zeilen: rest };\n}\n\n// ---------------------------------------------------------------------------\n// Blockbildung\n// ---------------------------------------------------------------------------\n\n/**\n * Zerlegt einen Textbereich anhand der Sprecherzeilen in Bl\u00f6cke.\n * Text vor der ersten Sprecherzeile wird verworfen (im Body ist das der\n * Protokollkopf, in den Anlagen die Anlagen\u00fcberschrift).\n */\nfunction bildeBloecke(zeilen) {\n  const bloecke = [];\n  let aktuell = null;\n\n  for (let i = 0; i < zeilen.length; i++) {\n    const sprecher = erkenneSprecher(zeilen[i]);\n    if (sprecher) {\n      aktuell = { ...sprecher, zeile: i, zeilen: [] };\n      bloecke.push(aktuell);\n    } else if (aktuell) {\n      aktuell.zeilen.push(zeilen[i]);\n    }\n  }\n\n  return bloecke;\n}\n\n/**\n * F\u00fchrt Bl\u00f6cke desselben Redners zusammen, die nur durch eine\n * Zwischenfragen-Regie des Pr\u00e4sidiums getrennt sind. Ohne diesen Schritt\n * z\u00e4hlt ein Sitzungstag 317-557 \"Reden\" statt der tats\u00e4chlichen 117-240.\n */\nfunction mergeReden(bloecke) {\n  const ergebnis = [];\n\n  for (const block of bloecke) {\n    if (block.rolle === 'Pr\u00e4sidium') {\n      ergebnis.push(block);\n      continue;\n    }\n\n    // R\u00fcckw\u00e4rts nach dem letzten Nicht-Pr\u00e4sidiums-Block suchen; dazwischen\n    // d\u00fcrfen nur kurze Pr\u00e4sidiumsbl\u00f6cke stehen. Deren Inhalt wird bewusst\n    // nicht gepr\u00fcft: die Unterbrechungen reichen von \"Erlauben Sie eine\n    // Zwischenfrage?\" \u00fcber \"Kommen Sie bitte zum Schluss.\" bis zu einem\n    // blossen \"Herr Kollege!\".\n    let j = ergebnis.length - 1;\n    let nurRegie = true;\n    while (j >= 0 && ergebnis[j].rolle === 'Pr\u00e4sidium') {\n      if (zaehleWoerter(ergebnis[j].zeilen.join(' ')) > REGIE_MAX_WOERTER) {\n        nurRegie = false;\n        break;\n      }\n      j--;\n    }\n\n    if (nurRegie && j >= 0 && ergebnis[j].name === block.name && ergebnis[j].rolle === block.rolle) {\n      ergebnis[j].zeilen.push(...block.zeilen);\n      ergebnis[j].fortgesetzt = true;\n      continue;\n    }\n\n    ergebnis.push(block);\n  }\n\n  return ergebnis;\n}\n\n// ---------------------------------------------------------------------------\n// Tagesordnungspunkte\n// ---------------------------------------------------------------------------\n\n/**\n * Baut aus dem Textblock, der einem TOP-Aufruf folgt, Titel, Unterpunkte und\n * Drucksachen. Direkt hinter \"Ich rufe auf die Tagesordnungspunkte 22a bis\n * 22d:\" steht der vollst\u00e4ndige Antragstext - vollst\u00e4ndiger als das\n * Inhaltsverzeichnis.\n */\nfunction leseAntragstext(zeilen) {\n  const relevant = [];\n  for (const roh of zeilen) {\n    const l = roh.trim();\n    if (!l) continue;\n    // Ab hier moderiert das Pr\u00e4sidium wieder, der Antragstext ist zu Ende.\n    if (\n      /^(F\u00fcr die Aussprache|Ich er\u00f6ffne die Aussprache|Nach einer interfraktionellen|Interfraktionell|Zu (dem|den) (Gesetzentwurf|Antr\u00e4gen|Antrag)|Wir kommen|Ich erteile|Das Wort (hat|erh\u00e4lt)|Es handelt sich)/.test(\n        l\n      )\n    ) {\n      break;\n    }\n    relevant.push(l);\n  }\n\n  const text = relevant.join('\\n');\n  const drucksachen = new Set();\n  for (const m of text.matchAll(RE_DRUCKSACHEN)) {\n    for (const nr of m[1].matchAll(RE_DRUCKSACHE_NR)) drucksachen.add(nr[0]);\n  }\n\n  const punkte = [];\n  for (const l of relevant) {\n    const m = RE_UNTERPUNKT.exec(bereinigeAgendaZeile(l));\n    if (m) {\n      const rest = m[2].replace(/^[\u2013\\-\\s]+/, '').trim();\n      if (rest) punkte.push({ buchstabe: m[1], titel: rest });\n    }\n  }\n\n  return { titel: baueTitel(relevant), punkte, drucksachen: [...drucksachen], text };\n}\n\n/**\n * Entfernt die Gliederungsmarken, mit denen der Antragstext im Fliesstext\n * einger\u00fcckt ist: \"ZP 28\\ta)\\t\u2013\\t...\", \"25\\ta)\\t...\", \"\u2013\\tBericht des ...\".\n */\nfunction bereinigeAgendaZeile(zeile) {\n  return zeile\n    .replace(/\\t/g, ' ')\n    .replace(/^\\s*(?:TOP|ZP)?\\s*\\d{1,3}\\s*(?=[a-z]\\)|\u2013|-)/, '')\n    .replace(/^[\u2013\\-\\s]+/, '')\n    .replace(/\\s{2,}/g, ' ')\n    .trim();\n}\n\n/** Zeilen, die keinen Titel liefern, sondern Fundstellen und Verfahrenswege. */\nconst RE_KEIN_TITEL = /^(?:Drucksachen?\\s|\u00dcberweisungsvorschlag|Beschlussempfehlung und Bericht|Bericht des Haushaltsausschusses|in Verbindung mit)/i;\n\n/**\n * Setzt den TOP-Titel aus den ersten Zeilen des Antragstextes zusammen.\n *\n * N\u00f6tig, weil der Gegenstand oft erst hinter der Einbringer-Formel steht:\n * \"Beratung des Antrags der Abgeordneten ... und der Fraktion Die Linke\" /\n * \"Bundesprogramm \u201eKultur macht stark\u201c bedarfsgerecht ausbauen\". Deshalb wird\n * weitergelesen, solange die letzte Zeile auf eine Fraktions- oder\n * Abgeordnetenaufz\u00e4hlung endet.\n */\nfunction baueTitel(zeilen) {\n  const teile = [];\n  for (const roh of zeilen) {\n    const l = bereinigeAgendaZeile(roh).replace(/^[a-z]\\)\\s*/, '').replace(/^[\u2013\\-\\s]+/, '');\n    if (!l || RE_KEIN_TITEL.test(l)) {\n      if (teile.length) break;\n      continue;\n    }\n    // Titelzeilen enden nie auf einen Punkt - sobald das Pr\u00e4sidium wieder in\n    // ganzen S\u00e4tzen moderiert (\"Ich bitte um z\u00fcgige Platzwechsel.\"), ist der\n    // Antragstext zu Ende.\n    if (/\\.$/.test(l) && !/\\b(?:Nr|Abs|Art|bzw|ff|GG|GO)\\.$/.test(l)) break;\n    teile.push(l);\n    const gesamt = teile.join(' ');\n    const haengtInDerLuft =\n      /(?:der |den |die )?Fraktion(?:en)?(?:\\s+(?:der|des|von))?\\s+[^,]{2,45}$/i.test(l) ||\n      /weiterer Abgeordneter$/i.test(l) ||\n      /:$/.test(l);\n    if (gesamt.length >= 60 && !haengtInDerLuft) break;\n    if (teile.length >= 4) break;\n  }\n  const titel = teile.join(' ').replace(/\\s{2,}/g, ' ').trim();\n  return titel.length > 3 ? titel.slice(0, 400) : null;\n}\n\n// ---------------------------------------------------------------------------\n// Anlagen\n// ---------------------------------------------------------------------------\n\n/**\n * Parst die Anlagen zum stenografischen Bericht. Interessant sind zwei\n * Sorten: zu Protokoll gegebene Reden (gehalten wurden sie nicht, gez\u00e4hlt\n * werden sie trotzdem) und Erkl\u00e4rungen nach \u00a7 31 GO, in denen Abweichler ihr\n * Abstimmungsverhalten begr\u00fcnden. Anlage 1 (entschuldigte Abgeordnete) und\n * \"Amtliche Mitteilungen\" werden verworfen.\n */\nfunction parseAnlagen(anlagenText) {\n  if (!anlagenText) return [];\n\n  const zeilen = anlagenText.split('\\n');\n  const abschnitte = [];\n  let aktuell = null;\n\n  for (let i = 0; i < zeilen.length; i++) {\n    const l = zeilen[i].trim();\n    const kopf = RE_ANLAGE_KOPF.exec(l);\n    if (kopf) {\n      aktuell = { nummer: Number(kopf[1]), kopfzeilen: [], zeilen: [], kopfEnde: false, start: i };\n      abschnitte.push(aktuell);\n      continue;\n    }\n    if (!aktuell) continue;\n    // Die Zeilen zwischen Anlagen\u00fcberschrift und erster Sprecherzeile\n    // beschreiben, worum es geht, und nennen den Bezugs-TOP.\n    if (!aktuell.kopfEnde) {\n      if (erkenneSprecher(l)) aktuell.kopfEnde = true;\n      else if (l && aktuell.kopfzeilen.length < 10) aktuell.kopfzeilen.push(l);\n    }\n    aktuell.zeilen.push(zeilen[i]);\n  }\n\n  const reden = [];\n  for (const abschnitt of abschnitte) {\n    const kopf = abschnitt.kopfzeilen.join(' ');\n    const istZuProtokoll = RE_ANLAGE_ZU_PROTOKOLL.test(kopf);\n    const ist31GO = RE_ANLAGE_31GO.test(kopf);\n    if (!istZuProtokoll && !ist31GO) continue;\n\n    // Bezugs-TOP steht als eigene Zeile: \"(Tagesordnungspunkt 22a)\".\n    let bezug = null;\n    for (const l of abschnitt.kopfzeilen) {\n      const m = RE_ANLAGE_BEZUG.exec(l.trim());\n      if (m) {\n        bezug = { typ: m[1], nummer: m[2], buchstabe: m[3] || null };\n        break;\n      }\n    }\n\n    for (const block of bildeBloecke(abschnitt.zeilen)) {\n      if (block.rolle === 'Pr\u00e4sidium') continue;\n      reden.push({\n        block,\n        zu_protokoll: istZuProtokoll,\n        erklaerung_31go: ist31GO,\n        anlage: abschnitt.nummer,\n        bezug,\n        kopf: kopf.slice(0, 400),\n      });\n    }\n  }\n\n  return reden;\n}\n\n// ---------------------------------------------------------------------------\n// Zuordnung der namentlichen Abstimmungen\n// ---------------------------------------------------------------------------\n\n/** Inhaltstragende W\u00f6rter eines Titels - kurze Funktionsw\u00f6rter fliegen raus. */\nfunction schlagworte(text) {\n  return new Set(\n    String(text || '')\n      .toLowerCase()\n      .replace(/[^a-z\u00e4\u00f6\u00fc\u00df0-9\\s]/g, ' ')\n      .split(/\\s+/)\n      .filter((w) => w.length >= 8)\n  );\n}\n\n/**\n * Ordnet jede namentliche Abstimmung dem TOP zu, um den es tats\u00e4chlich geht.\n *\n * Notwendig, weil das Pr\u00e4sidium Ergebnisse sammelt und sie parallel zu einer\n * ganz anderen Debatte verliest: in 21/90 werden f\u00fcnf Abstimmungen zum\n * GKV-Gesetz (TOP 22) w\u00e4hrend der Zusatzpunkte 18, 28 und 22 bekannt gegeben.\n * Erste Wahl ist die Drucksachennummer, zweite die Wort\u00fcberschneidung des\n * verlesenen Gegenstands mit dem TOP-Titel.\n */\nfunction findeTopFuerAbstimmung(tops, abstimmung) {\n  for (const nr of abstimmung.drucksachen) {\n    const treffer = tops.find((t) => t.drucksachen.includes(nr));\n    if (treffer) return { top: treffer, weg: 'drucksache' };\n  }\n\n  if (abstimmung.verlesen) {\n    const worte = schlagworte(abstimmung.verlesen);\n    let bestesErgebnis = 0;\n    let ziel = null;\n    for (const top of tops) {\n      let treffer = 0;\n      for (const w of schlagworte(top.titel)) if (worte.has(w)) treffer++;\n      if (treffer > bestesErgebnis) { bestesErgebnis = treffer; ziel = top; }\n    }\n    if (ziel && bestesErgebnis >= 3) return { top: ziel, weg: 'titel' };\n  }\n\n  return null;\n}\n\nfunction ordneAbstimmungenZu(tops, topsNachKey, abstimmungen) {\n  for (const abstimmung of abstimmungen) {\n    const treffer = findeTopFuerAbstimmung(tops, abstimmung);\n    const ziel = treffer ? treffer.top : topsNachKey.get(abstimmung.topKey);\n    abstimmung.topKey = ziel.key;\n    abstimmung.zugeordnetUeber = treffer ? treffer.weg : 'position';\n    ziel.abstimmungen.push(abstimmung);\n  }\n}\n\n// ---------------------------------------------------------------------------\n// Hauptfunktion\n// ---------------------------------------------------------------------------\n\n/**\n * @param {object} dokument  Ein Dokument aus documents[] von /plenarprotokoll-text\n * @param {object} [optionen]\n * @param {number} [optionen.minWoerter=120]  Ab wie vielen W\u00f6rtern eine Rede\n *        einzeln vom LLM analysiert wird. K\u00fcrzere werden `kurz: true`.\n * @param {number} [optionen.buendelnAbMedian=200]  Liegt der Median der\n *        Redel\u00e4ngen eines TOP darunter, wird der TOP geb\u00fcndelt.\n * @param {boolean} [optionen.anlagen=true]  Anlagen mit auswerten.\n */\nfunction parseProtokoll(dokument, optionen = {}) {\n  const opt = {\n    minWoerter: 120,\n    buendelnAbMedian: 200,\n    anlagen: true,\n    ...optionen,\n  };\n\n  if (!dokument || typeof dokument.text !== 'string') {\n    throw new Error('parseProtokoll: Dokument ohne Feld \"text\" - stammt es von /plenarprotokoll-text?');\n  }\n\n  // --- 1. Normalisieren ----------------------------------------------------\n  const text = normalisiereText(dokument.text);\n\n  // --- 2. Zonen trennen ----------------------------------------------------\n  const anlagenPos = text.indexOf(MARKER_ANLAGEN);\n  const vorAnlagen = anlagenPos >= 0 ? text.slice(0, anlagenPos) : text;\n  const anlagenText = anlagenPos >= 0 ? text.slice(anlagenPos) : '';\n\n  const alleZeilen = vorAnlagen.split('\\n');\n  let bodyStart = alleZeilen.findIndex((z) => erkenneSprecher(z));\n  if (bodyStart < 0) bodyStart = alleZeilen.length;\n  const inhaltsverzeichnis = alleZeilen.slice(0, bodyStart).join('\\n');\n  const bodyZeilen = alleZeilen.slice(bodyStart);\n\n  // --- 3./4. Bl\u00f6cke bilden und Reden zusammenf\u00fchren ------------------------\n  const rohBloecke = bildeBloecke(bodyZeilen);\n  const bloecke = mergeReden(rohBloecke);\n\n  // --- 5.-8. Bl\u00f6cke auswerten ---------------------------------------------\n  const tops = [];\n  const topsNachKey = new Map();\n  const reden = [];\n  const alleAbstimmungen = [];\n  let aktuellerTop = null;\n\n  /** Legt einen TOP an oder liefert den bestehenden - Aufrufe wiederholen sich. */\n  function holeTop(typ, nummer, buchstabe) {\n    const key = `${typ === 'Zusatzpunkt' ? 'ZP' : 'TOP'} ${nummer}`;\n    if (topsNachKey.has(key)) return topsNachKey.get(key);\n    const top = {\n      key,\n      typ,\n      nummer,\n      buchstabe: buchstabe || null,\n      titel: null,\n      punkte: [],\n      drucksachen: [],\n      beschluesse: [],\n      abstimmungen: [],\n      redeIds: [],\n      gebuendelt: false,\n    };\n    tops.push(top);\n    topsNachKey.set(key, top);\n    return top;\n  }\n\n  const eroeffnung = {\n    key: 'Er\u00f6ffnung',\n    typ: 'Er\u00f6ffnung',\n    nummer: null,\n    buchstabe: null,\n    titel: 'Er\u00f6ffnung und Gesch\u00e4ftsordnung',\n    punkte: [],\n    drucksachen: [],\n    beschluesse: [],\n    abstimmungen: [],\n    redeIds: [],\n    gebuendelt: false,\n  };\n  tops.push(eroeffnung);\n  topsNachKey.set(eroeffnung.key, eroeffnung);\n  aktuellerTop = eroeffnung;\n\n  for (const block of bloecke) {\n    if (block.rolle === 'Pr\u00e4sidium') {\n      // Erst die Namenslisten herausziehen, sonst verseuchen sie sowohl den\n      // Beschlusstext als auch jeden sp\u00e4teren Prompt.\n      const { abstimmungen, zeilen } = extrahiereNamentlicheAbstimmungen(block.zeilen);\n      const { text: praesText } = trenneZwischenrufe(zeilen);\n\n      // TOP-Aufrufe: der neue TOP gilt ab dem folgenden Block.\n      let neuerTop = null;\n      let aufrufIndex = -1;\n      const praesZeilen = praesText.split('\\n');\n      for (let i = 0; i < praesZeilen.length; i++) {\n        const m = RE_TOP_AUFRUF.exec(praesZeilen[i]);\n        if (m) {\n          neuerTop = holeTop(m[1], m[2], m[3]);\n          aufrufIndex = i;\n        }\n      }\n\n      // Beschlusss\u00e4tze zuordnen. Ausgangspunkt ist der laufende TOP - sie\n      // beziehen sich auf das gerade Abgestimmte, nicht auf das neu\n      // Aufgerufene. Der Kontext wandert aber mit, sobald das Pr\u00e4sidium zu\n      // einem fr\u00fcheren TOP zur\u00fcckkehrt (\"Ich komme zur\u00fcck zum\n      // Tagesordnungspunkt 22a\") oder eine Drucksache nennt, die eindeutig zu\n      // einem anderen TOP geh\u00f6rt.\n      const bisAufruf = aufrufIndex >= 0 ? praesZeilen.slice(0, aufrufIndex + 1) : praesZeilen;\n      // Enth\u00e4lt der Block ein verlesenes Abstimmungsergebnis, geh\u00f6ren die\n      // Zahlens\u00e4tze zu dessen TOP - nicht zu dem, der gerade debattiert wird.\n      const abstimmungsTreffer = abstimmungen.length\n        ? findeTopFuerAbstimmung(tops, abstimmungen[0])\n        : null;\n      let beschlussTop = aktuellerTop;\n      for (const absatz of bisAufruf) {\n        for (const satz of inSaetze(absatz)) {\n          if (abstimmungsTreffer && RE_STIMMENZAHLEN.test(satz)) {\n            beschlussTop = abstimmungsTreffer.top;\n          }\n          const rueckkehr = RE_RUECKKEHR.exec(satz);\n          if (rueckkehr) {\n            const ziel = topsNachKey.get(\n              `${rueckkehr[1] === 'Zusatzpunkt' ? 'ZP' : 'TOP'} ${rueckkehr[2]}`\n            );\n            if (ziel) beschlussTop = ziel;\n          } else {\n            for (const nr of satz.match(RE_DRUCKSACHE_NR) || []) {\n              const ziel = tops.find((t) => t.drucksachen.includes(nr));\n              if (ziel) { beschlussTop = ziel; break; }\n            }\n          }\n\n          if (RE_BESCHLUSS.test(satz) && RE_BESCHLUSS_ERGEBNIS.test(satz) && satz.length < 600) {\n            beschlussTop.beschluesse.push(satz);\n          }\n        }\n      }\n\n      // Die Zuordnung zum TOP folgt erst nach der Schleife: das Ergebnis einer\n      // namentlichen Abstimmung wird oft erst Stunden sp\u00e4ter, mitten in einer\n      // ganz anderen Debatte, verlesen.\n      for (const abstimmung of abstimmungen) {\n        alleAbstimmungen.push({ topKey: aktuellerTop.key, ...abstimmung });\n      }\n\n      if (neuerTop) {\n        const antrag = leseAntragstext(praesZeilen.slice(aufrufIndex + 1));\n        if (!neuerTop.titel && antrag.titel) neuerTop.titel = antrag.titel;\n        if (!neuerTop.punkte.length) neuerTop.punkte = antrag.punkte;\n        neuerTop.drucksachen = [...new Set([...neuerTop.drucksachen, ...antrag.drucksachen])];\n        aktuellerTop = neuerTop;\n      }\n      continue;\n    }\n\n    const { text: redeText, interjektionen } = trenneZwischenrufe(block.zeilen);\n    const woerter = zaehleWoerter(redeText);\n    if (!woerter) continue;\n\n    const rede = {\n      id: `r${reden.length + 1}`,\n      topKey: aktuellerTop.key,\n      rolle: block.rolle,\n      name: block.name,\n      wahlkreis: block.wahlkreis || null,\n      fraktion: block.fraktion,\n      funktion: block.funktion || null,\n      text: redeText,\n      woerter,\n      kurz: woerter < opt.minWoerter,\n      fortgesetzt: Boolean(block.fortgesetzt),\n      zu_protokoll: false,\n      erklaerung_31go: false,\n      interjektionen,\n    };\n    reden.push(rede);\n    aktuellerTop.redeIds.push(rede.id);\n  }\n\n  // --- 9. Anlagen ----------------------------------------------------------\n  if (opt.anlagen) {\n    for (const eintrag of parseAnlagen(anlagenText)) {\n      const { text: redeText, interjektionen } = trenneZwischenrufe(eintrag.block.zeilen);\n      const woerter = zaehleWoerter(redeText);\n      if (!woerter) continue;\n\n      let topKey = 'Er\u00f6ffnung';\n      if (eintrag.bezug) {\n        const kandidat = `${eintrag.bezug.typ === 'Zusatzpunkt' ? 'ZP' : 'TOP'} ${eintrag.bezug.nummer}`;\n        if (topsNachKey.has(kandidat)) topKey = kandidat;\n      }\n\n      const rede = {\n        id: `r${reden.length + 1}`,\n        topKey,\n        rolle: eintrag.block.rolle,\n        name: eintrag.block.name,\n        wahlkreis: eintrag.block.wahlkreis || null,\n        fraktion: eintrag.block.fraktion,\n        funktion: eintrag.block.funktion || null,\n        text: redeText,\n        woerter,\n        kurz: woerter < opt.minWoerter,\n        fortgesetzt: false,\n        zu_protokoll: eintrag.zu_protokoll,\n        erklaerung_31go: eintrag.erklaerung_31go,\n        anlage: eintrag.anlage,\n        interjektionen,\n      };\n      reden.push(rede);\n      const top = topsNachKey.get(topKey);\n      if (top) top.redeIds.push(rede.id);\n    }\n  }\n\n  // --- 9b. Namentliche Abstimmungen ihren TOPs zuordnen --------------------\n  ordneAbstimmungenZu(tops, topsNachKey, alleAbstimmungen);\n\n  // --- 10. Tagestyp und B\u00fcndelung -----------------------------------------\n  const redenNachId = new Map(reden.map((r) => [r.id, r]));\n  for (const top of tops) {\n    const laengen = top.redeIds.map((id) => redenNachId.get(id).woerter);\n    const med = median(laengen);\n    top.medianWoerter = med;\n    top.anzahlReden = laengen.length;\n    top.gebuendelt =\n      laengen.length >= 5 &&\n      (med < opt.buendelnAbMedian || RE_BEFRAGUNGS_TOP.test(top.titel || ''));\n  }\n\n  const debattenReden = reden.filter((r) => r.rolle !== 'Pr\u00e4sidium');\n  const tagesMedian = median(debattenReden.map((r) => r.woerter));\n\n  // Leere Pseudo-TOPs (kein Wortbeitrag, kein Beschluss) fallen raus.\n  const gefilterteTops = tops.filter(\n    (t) => t.redeIds.length || t.beschluesse.length || t.abstimmungen.length\n  );\n\n  const zuAnalysieren = reden.filter((r) => {\n    const top = topsNachKey.get(r.topKey);\n    return !r.kurz && !(top && top.gebuendelt);\n  });\n\n  return {\n    meta: {\n      id: dokument.id ?? null,\n      dokumentnummer: dokument.dokumentnummer ?? null,\n      wahlperiode: dokument.wahlperiode ?? null,\n      sitzung: dokument.dokumentnummer ? String(dokument.dokumentnummer).split('/')[1] ?? null : null,\n      datum: dokument.datum ?? null,\n      aktualisiert: dokument.aktualisiert ?? null,\n      titel: dokument.titel ?? null,\n      herausgeber: dokument.herausgeber ?? null,\n      pdf_url: dokument.fundstelle?.pdf_url ?? null,\n      xml_url: dokument.fundstelle?.xml_url ?? null,\n      sitzungsbemerkung: dokument.sitzungsbemerkung ?? null,\n      tagestyp: tagesMedian < opt.buendelnAbMedian ? 'befragung' : 'debatte',\n      tagesMedianWoerter: tagesMedian,\n    },\n    tops: gefilterteTops,\n    reden,\n    namentlicheAbstimmungen: alleAbstimmungen,\n    statistik: {\n      zeichenGesamt: text.length,\n      zeichenInhaltsverzeichnis: inhaltsverzeichnis.length,\n      bloeckeRoh: rohBloecke.length,\n      redenGesamt: reden.length,\n      redenDebatte: debattenReden.length,\n      redenAnalysierbar: zuAnalysieren.length,\n      redenAusAnlagen: reden.filter((r) => r.zu_protokoll || r.erklaerung_31go).length,\n      praesidiumsbloecke: bloecke.filter((b) => b.rolle === 'Pr\u00e4sidium').length,\n      regierungsreden: reden.filter((r) => r.rolle === 'Regierung').length,\n      tops: gefilterteTops.length,\n      namentlicheAbstimmungen: alleAbstimmungen.length,\n      interjektionen: reden.reduce((s, r) => s + r.interjektionen.length, 0),\n    },\n  };\n}\n// ===================== Ende lib/parse-protokoll.js =====================\n\n\nconst konfig = $('Konfiguration').first().json;\n\nconst dokument = $input.first().json;\nconst ergebnis = parseProtokoll(dokument, {\n  minWoerter: konfig.minWoerter,\n  buendelnAbMedian: konfig.buendelnAbMedian,\n  anlagen: konfig.anlagenAuswerten !== false,\n});\n\n// Ab hier wird der Volltext nicht mehr gebraucht. Er bewusst nicht\n// weiterzureichen ist der Unterschied zwischen 2 MB und 250 x 2 MB im Speicher.\nlet tops = ergebnis.tops;\nif (konfig.nurTops > 0) {\n  tops = tops.slice(0, konfig.nurTops);\n  const erlaubt = new Set(tops.map((t) => t.key));\n  ergebnis.reden = ergebnis.reden.filter((r) => erlaubt.has(r.topKey));\n}\n\nconst gebuendelt = new Set(tops.filter((t) => t.gebuendelt).map((t) => t.key));\nconst zuAnalysieren = ergebnis.reden.filter((r) => !r.kurz && !gebuendelt.has(r.topKey));\n\nif (!zuAnalysieren.length) {\n  // Ohne Items l\u00e4uft die nachfolgende Schleife nie an und der Workflow bliebe\n  // stumm stehen. Lieber laut scheitern - und die Meldung sagt, wo man dreht.\n  throw new Error(\n    `Protokoll ${ergebnis.meta.dokumentnummer}: keine Rede \u00fcber ${konfig.minWoerter} W\u00f6rter gefunden ` +\n      `(${ergebnis.statistik.redenGesamt} Reden erkannt). minWoerter senken oder das Protokoll mit ` +\n      'scripts/inspect-protokoll.mjs pr\u00fcfen.'\n  );\n}\n\nreturn [\n  {\n    json: {\n      meta: ergebnis.meta,\n      tops,\n      reden: ergebnis.reden,\n      namentlicheAbstimmungen: ergebnis.namentlicheAbstimmungen,\n      statistik: ergebnis.statistik,\n      zuAnalysieren,\n      anzahlZuAnalysieren: zuAnalysieren.length,\n    },\n  },\n];\n"
      },
      "id": "parsen",
      "name": "Protokoll parsen",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        220,
        -60
      ],
      "notes": "Herzst\u00fcck: lib/parse-protokoll.js, eingesetzt von scripts/build-workflow.mjs. Nicht hier bearbeiten, sondern im Repository."
    },
    {
      "parameters": {
        "fieldToSplitOut": "zuAnalysieren",
        "options": {}
      },
      "id": "reden-aufteilen",
      "name": "Reden aufteilen",
      "type": "n8n-nodes-base.splitOut",
      "typeVersion": 1,
      "position": [
        440,
        -60
      ]
    },
    {
      "parameters": {
        "options": {}
      },
      "id": "reden-schleife",
      "name": "Reden durchlaufen",
      "type": "n8n-nodes-base.splitInBatches",
      "typeVersion": 3,
      "position": [
        660,
        -60
      ],
      "notes": "100 bis 180 Durchl\u00e4ufe an einem Debattentag."
    },
    {
      "parameters": {
        "jsCode": "// Baut den Prompt f\u00fcr die Einzelanalyse einer Rede.\n//\n// Der Prompt liegt als prompts/01-rede-analysieren.md im Repository und wird\n// von scripts/build-workflow.mjs hier eingesetzt - so bleibt er lesbar und\n// versioniert, statt in einem escapten JSON-String zu verschwinden.\n\nconst VORLAGE = \"Du analysierst einen einzelnen Redebeitrag aus einer Plenarsitzung des Deutschen Bundestages.\\n\\n## Regeln\\n\\n- Nutze **ausschlie\u00dflich** den unten gelieferten Text. Wenn eine Information darin nicht steht,\\n  schreibe `null` beziehungsweise `\\\"nicht erkennbar\\\"`.\\n- Erfinde **keine** Abstimmungsergebnisse, Zahlen, Drucksachennummern oder Zitate.\\n- Das Zitat muss **wortw\u00f6rtlich** aus dem Redetext stammen.\\n- Antworte **nur** mit einem JSON-Objekt, ohne Vorrede, ohne Markdown-Codeblock.\\n\\n## Hinweis zu den Reaktionen\\n\\nDie Liste der Reaktionen stammt aus den Klammerbemerkungen des Stenografischen Berichts.\\nWelche Fraktion applaudiert und welche dazwischenruft, ist ein verl\u00e4ssliches Signal daf\u00fcr,\\nwer die Position des Redners teilt. Nutze es, \u00fcberinterpretiere es aber nicht.\\n\\n## Ausgabeschema\\n\\n```json\\n{\\n  \\\"kernaussagen\\\": [\\\"3 bis 5 knappe S\u00e4tze, je eine inhaltliche Aussage\\\"],\\n  \\\"position\\\": \\\"daf\u00fcr | dagegen | differenziert | nicht erkennbar\\\",\\n  \\\"begruendung\\\": \\\"Ein bis zwei S\u00e4tze: warum diese Position?\\\",\\n  \\\"forderungen\\\": [\\\"konkrete Forderungen an Regierung, Parlament oder Dritte\\\"],\\n  \\\"kritik_an\\\": [\\\"Fraktionen, Personen oder Institutionen, die kritisiert werden\\\"],\\n  \\\"zitat\\\": \\\"ein pr\u00e4gnanter, w\u00f6rtlicher Satz aus der Rede\\\"\\n}\\n```\\n\\n`position` bezieht sich auf den beratenen Gegenstand des Tagesordnungspunktes, nicht auf\\ndas Thema im Allgemeinen. Beim Pr\u00e4sidium und bei rein verfahrensleitenden Beitr\u00e4gen ist\\n`\\\"nicht erkennbar\\\"` die richtige Antwort.\\n\\n---\\n\\n## Tagesordnungspunkt\\n\\n{{TOP_TITEL}}\\n\\n## Redner\\n\\n{{REDNER}} ({{FRAKTION}}, {{ROLLE}}{{FUNKTION}})\\n\\n## Reaktionen im Plenum\\n\\n{{REAKTIONEN}}\\n\\n## Redetext\\n\\n{{REDETEXT}}\\n\";\n\nconst parse = $('Protokoll parsen').first().json;\nconst topsNachKey = new Map(parse.tops.map((t) => [t.key, t]));\nconst konfig = $('Konfiguration').first().json;\n\n/**\n * Verdichtet die Zwischenrufe zu einer Zeile pro Reaktionstyp. Wer applaudiert\n * und wer dazwischenruft, ist das h\u00e4rteste Signal f\u00fcr N\u00e4he und Distanz zur\n * vorgetragenen Position.\n */\nfunction reaktionen(interjektionen) {\n  if (!interjektionen || !interjektionen.length) return 'Keine verzeichnet.';\n\n  const beifallVon = new Set();\n  const zurufeVon = new Set();\n  const zitate = [];\n  for (const i of interjektionen) {\n    if (i.typ === 'beifall') i.fraktionen.forEach((f) => beifallVon.add(f));\n    else i.fraktionen.forEach((f) => zurufeVon.add(f));\n    if (i.typ === 'zuruf' && i.text.includes(':') && zitate.length < 5) zitate.push(i.text);\n  }\n\n  const zeilen = [];\n  zeilen.push(`- Beifall von: ${beifallVon.size ? [...beifallVon].join(', ') : 'nicht zugeordnet'}`);\n  zeilen.push(`- Zwischenrufe von: ${zurufeVon.size ? [...zurufeVon].join(', ') : 'nicht zugeordnet'}`);\n  if (zitate.length) zeilen.push(`- Beispiele: ${zitate.map((z) => `\u201e${z}\u201c`).join(' \u00b7 ')}`);\n  return zeilen.join('\\n');\n}\n\nreturn $input.all().map((item) => {\n  const rede = item.json;\n  const top = topsNachKey.get(rede.topKey) || {};\n  const rolle =\n    rede.erklaerung_31go\n      ? 'Erkl\u00e4rung nach \u00a7 31 GO zum eigenen Abstimmungsverhalten'\n      : rede.zu_protokoll\n        ? 'zu Protokoll gegebene Rede'\n        : rede.rolle;\n\n  const prompt = VORLAGE.replace('{{TOP_TITEL}}', `${top.key || rede.topKey}: ${top.titel || ''}`)\n    .replace('{{REDNER}}', rede.name)\n    .replace('{{FRAKTION}}', rede.fraktion)\n    .replace('{{ROLLE}}', rolle)\n    .replace('{{FUNKTION}}', rede.funktion ? `, ${rede.funktion}` : '')\n    .replace('{{REAKTIONEN}}', reaktionen(rede.interjektionen))\n    .replace('{{REDETEXT}}', rede.text.slice(0, konfig.maxZeichenRede));\n\n  return {\n    json: {\n      redeId: rede.id,\n      topKey: rede.topKey,\n      name: rede.name,\n      fraktion: rede.fraktion,\n      rolle: rede.rolle,\n      zu_protokoll: Boolean(rede.zu_protokoll),\n      erklaerung_31go: Boolean(rede.erklaerung_31go),\n      prompt,\n    },\n  };\n});\n"
      },
      "id": "map-prompt",
      "name": "MAP-Prompt bauen",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        880,
        60
      ]
    },
    {
      "parameters": {
        "promptType": "define",
        "text": "={{ $json.prompt }}",
        "messages": {}
      },
      "id": "map-chain",
      "name": "Rede analysieren",
      "type": "@n8n/n8n-nodes-langchain.chainLlm",
      "typeVersion": 1.4,
      "position": [
        1100,
        60
      ],
      "onError": "continueRegularOutput",
      "retryOnFail": true,
      "maxTries": 2
    },
    {
      "parameters": {
        "jsCode": "// Liest das JSON aus der Antwort des lokalen Modells - defensiv, damit ein\n// einzelner Ausrutscher nicht den ganzen Lauf killt.\n\n// ==========================================================================\n// Eingebettet aus workflows/nodes/lib-json-schneiden.js - NICHT hier bearbeiten.\n// Quelle im Repository \u00e4ndern und \"node scripts/build-workflow.mjs\" laufen lassen.\n// ==========================================================================\n// Gemeinsamer Baustein: JSON aus einer beliebigen Modellantwort herausschneiden.\n//\n// Kein Structured-Output-Parser-Node: lokale Modelle stellen regelm\u00e4\u00dfig\n// Vorreden voran, packen die Antwort in einen Codeblock oder h\u00e4ngen einen\n// <think>-Block an. Ein strikter Parser w\u00fcrde daran den ganzen Lauf abbrechen -\n// nach 150 erfolgreichen Aufrufen.\n//\n// Wird von scripts/build-workflow.mjs in die Code-Nodes eingesetzt, die\n// Modellantworten auswerten.\n\n/**\n * Schneidet das erste vollst\u00e4ndige JSON-Objekt heraus: vom ersten \"{\" bis zur\n * passenden schlie\u00dfenden Klammer, Anf\u00fchrungszeichen und Escapes ber\u00fccksichtigt.\n */\nfunction schneideJson(text) {\n  const s = String(text ?? '').replace(/<think>[\\s\\S]*?<\\/think>/gi, '');\n  const start = s.indexOf('{');\n  if (start < 0) return null;\n\n  let tiefe = 0;\n  let inString = false;\n  let escaped = false;\n  for (let i = start; i < s.length; i++) {\n    const c = s[i];\n    if (escaped) { escaped = false; continue; }\n    if (c === '\\\\') { escaped = true; continue; }\n    if (c === '\"') { inString = !inString; continue; }\n    if (inString) continue;\n    if (c === '{') tiefe++;\n    else if (c === '}') {\n      tiefe--;\n      if (tiefe === 0) return s.slice(start, i + 1);\n    }\n  }\n  return null;\n}\n\nfunction alsObjekt(antwort) {\n  const roh = schneideJson(antwort);\n  if (!roh) return null;\n  try {\n    return JSON.parse(roh);\n  } catch {\n    // Zweiter Versuch: ein nachlaufendes Komma ist der h\u00e4ufigste Fehler.\n    try {\n      return JSON.parse(roh.replace(/,(\\s*[}\\]])/g, '$1'));\n    } catch {\n      return null;\n    }\n  }\n}\n// ===================== Ende workflows/nodes/lib-json-schneiden.js =====================\n\n\nconst LEERE_ANALYSE = {\n  fehlgeschlagen: true,\n  kernaussagen: [],\n  position: 'nicht erkennbar',\n  begruendung: null,\n  forderungen: [],\n  kritik_an: [],\n  zitat: null,\n};\n\nreturn $input.all().map((item) => {\n  const analyse = alsObjekt(item.json.text ?? item.json.output ?? item.json.response ?? '');\n  const { text, output, response, prompt, ...rest } = item.json;\n\n  return {\n    json: {\n      ...rest,\n      analyse: analyse || LEERE_ANALYSE,\n      analyseOk: Boolean(analyse),\n    },\n  };\n});\n"
      },
      "id": "map-parsen",
      "name": "Rede-JSON pars
Pro

For the full experience including quality scoring and batch install features for each workflow upgrade to Pro

About this workflow

Bundestag: Plenarprotokoll parsen und mit Ollama zusammenfassen. Uses httpRequest, chainLlm, readWriteFile, lmChatOllama. Event-driven trigger; 27 nodes.

Source: https://github.com/miguletztim/bundestag-scraper/blob/d0c726c0f60698b8030e700c1c49971cc2b00c6f/workflows/bundestag-protokoll-daily.json — original creator credit. Request a take-down →

More AI & RAG workflows → · Browse all categories →

Related workflows

Workflows that share integrations, category, or trigger type with this one. All free to copy and import.

AI & RAG

Episode 11: AI shorts factory app. Uses httpRequest, googleSheets, lmChatOpenAi, lmChatOllama. Event-driven trigger; 96 nodes.

HTTP Request, Google Sheets, OpenAI Chat +15
AI & RAG

My workflow 53. Uses formTrigger, httpRequest, lmChatOpenAi, form. Event-driven trigger; 74 nodes.

Form Trigger, HTTP Request, OpenAI Chat +15
AI & RAG

Episode 23: UGC with nanobanana. Uses lmChatOpenAi, lmChatOllama, lmChatDeepSeek, lmChatOpenRouter. Event-driven trigger; 74 nodes.

OpenAI Chat, Ollama Chat, Lm Chat Deep Seek +12
AI & RAG

🤖🧑‍💻 AI Agent for Top n8n Creators Leaderboard Reporting. Uses httpRequest, lmChatOpenAi, executeWorkflowTrigger, toolWorkflow. Event-driven trigger; 49 nodes.

HTTP Request, OpenAI Chat, Execute Workflow Trigger +8
AI & RAG

🤖🧑‍💻 AI Agent for Top n8n Creators Leaderboard Reporting. Uses httpRequest, lmChatOpenAi, executeWorkflowTrigger, toolWorkflow. Event-driven trigger; 49 nodes.

HTTP Request, OpenAI Chat, Execute Workflow Trigger +8