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(?:en)?$|^union$/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/**\n * Art der Vorlage, \u00fcber die abgestimmt wurde - steht im Ergebnissatz ohnehin\n * drin. Die Reihenfolge ist die Rangfolge: taucht in einem Tagesordnungspunkt\n * ein Gesetzentwurf auf, tr\u00e4gt er den Eintrag, und die Entschlie\u00dfungs- und\n * \u00c4nderungsantr\u00e4ge werden zu Randnotizen.\n */\nconst BESCHLUSS_ARTEN = [\n  // Im Ergebnissatz steht \"Gesetzentwurf\", im Vorspann des Tagesordnungspunktes\n  // dagegen \"Entwurf eines Gesetzes zur ...\" - beides muss greifen.\n  ['gesetzentwurf', /Gesetzentwurf|Gesetzesentwurf|Entwurfs? eines Gesetzes|Gesetzes? (?:zur|zum|\u00fcber|f\u00fcr)/i],\n  ['antrag', new RegExp(WB + '(?:Antrag|Antrags|Antr\u00e4ge)' + WE, 'i')],\n  ['beschlussempfehlung', /Beschlussempfehlung/i],\n  ['entschliessungsantrag', /Entschlie\u00dfungsantrag/i],\n  ['aenderungsantrag', /\u00c4nderungsantrag/i],\n  ['ueberweisung', new RegExp(WB + '(?:\u00dcberweisung|\u00fcberwiesen)' + WE, 'i')],\n  ['geschaeftsordnung', /Tagesordnung|Aufsetzungsantrag|Gesch\u00e4ftsordnung/i],\n];\n\n/**\n * Welche Beschlussart f\u00fcr welche Vorlage einspringen darf, wenn kein Ergebnis\n * mit der passenden Art vorliegt.\n */\nconst VERWANDTE_ARTEN = {\n  gesetzentwurf: ['beschlussempfehlung', 'ueberweisung'],\n  antrag: ['beschlussempfehlung', 'ueberweisung'],\n  beschlussempfehlung: ['antrag', 'ueberweisung'],\n};\n\n/** Rangfolge f\u00fcr die Wahl des Hauptbeschlusses eines Tagesordnungspunktes. */\nconst HAUPTBESCHLUSS_RANG = [\n  'gesetzentwurf',\n  'antrag',\n  'beschlussempfehlung',\n  'ueberweisung',\n  'entschliessungsantrag',\n  'aenderungsantrag',\n  'geschaeftsordnung',\n];\n\n/** Ergebnis eines Beschlusses. */\nconst RE_ANGENOMMEN = new RegExp(WB + '(?:angenommen|zugestimmt|so beschlossen)' + WE, 'i');\nconst RE_ABGELEHNT = new RegExp(WB + '(?:abgelehnt|keine Mehrheit|nicht angenommen)' + WE, 'i');\nconst RE_UEBERWIESEN = new RegExp(WB + '(?:\u00fcberwiesen|\u00dcberweisung)' + WE, 'i');\n\n/**\n * Die Frageformeln einer einfachen Abstimmung. Das Pr\u00e4sidium fragt, das Haus\n * antwortet, und die Antwort steht direkt hinter dem Gedankenstrich:\n *\n *   Wer stimmt f\u00fcr diese Beschlussempfehlung? \u2013 CDU/CSU und SPD.\n *   Gegenprobe! \u2013 AfD.  Enthaltungen? \u2013 B\u00fcndnis 90/Die Gr\u00fcnen, Die Linke.\n *\n * Die Liste muss vollst\u00e4ndig sein: fehlt eine Formel, endet das Auswertefenster\n * der vorigen Frage nicht und die Enthaltungen landen bei den Gegenstimmen.\n */\nconst LAGER_FRAGEN = [\n  [\n    'dafuer',\n    /(?:Wer stimmt (?:daf\u00fcr|f\u00fcr [^?]{0,90})|Wer ist daf\u00fcr|Ich bitte (?:um das Handzeichen|diejenigen[^.?]{0,140}?um das Handzeichen))[?.!]/i,\n  ],\n  ['dagegen', /(?:Wer stimmt dagegen|Gegenprobe|Wer ist dagegen|Wer stimmt gegen[^?]{0,80})[?.!]/i],\n  [\n    'enthalten',\n    /(?:Wer enth\u00e4lt sich|Enthaltungen|Wer m\u00f6chte sich enthalten|Gibt es Enthaltungen)[?.!]/i,\n  ],\n];\n\n/** Alle Frageformeln zusammen - sie begrenzen das Fenster der jeweils vorigen. */\nconst RE_LAGER_GRENZE =\n  /(?:Wer stimmt|Wer ist daf\u00fcr|Wer ist dagegen|Gegenprobe|Wer enth\u00e4lt sich|Enthaltungen|Wer m\u00f6chte sich enthalten|Ich bitte|Damit ist|Hiermit ist|Damit hat|Der Gesetzentwurf ist|Die Beschlussempfehlung ist|Der Antrag ist|Der Entschlie\u00dfungsantrag ist)/i;\n\n/** Antworten, die keine Fraktion nennen. */\nconst RE_LAGER_NIEMAND = /Kann ich nicht erkennen|niemand|keine Enthaltungen|sehe ich nicht|Das ist nicht der Fall/i;\nconst RE_LAGER_ALLE_ANDEREN = /alle anderen|die \u00fcbrigen Fraktionen|das gesamte Haus|einstimmig/i;\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/**\n * Bestimmt Art und Ergebnis eines Beschlusses aus seinem Ergebnissatz.\n * Grundlage sind die Substantive, die der Satz ohnehin nennt - gemessen an 223\n * Beschlusss\u00e4tzen aus acht Protokollen deckt das alles ab, wor\u00fcber im Plenum\n * abgestimmt wird.\n */\nfunction klassifiziereBeschluss(satz) {\n  let art = 'sonstiges';\n  for (const [name, muster] of BESCHLUSS_ARTEN) {\n    if (muster.test(satz)) { art = name; break; }\n  }\n\n  let ergebnis = null;\n  if (RE_ANGENOMMEN.test(satz)) ergebnis = 'angenommen';\n  else if (RE_ABGELEHNT.test(satz)) ergebnis = 'abgelehnt';\n  else if (RE_UEBERWIESEN.test(satz)) ergebnis = '\u00fcberwiesen';\n\n  return { art, ergebnis };\n}\n\n/**\n * Liest aus einer Abstimmungspassage, welche Fraktionen daf\u00fcr, dagegen und\n * enthalten waren.\n *\n * Das Fenster hinter jeder Frage endet an der n\u00e4chsten Frageformel - sonst\n * wandern die Enthaltungen in die Gegenstimmen, weil beide Antworten im selben\n * Absatz stehen.\n *\n * @param {string} passage  Der Absatz mit Frage, Antwort und Ergebnissatz\n * @returns {{dafuer: string[], dagegen: string[], enthalten: string[]}}\n */\nfunction extrahiereLager(passage) {\n  const lager = { dafuer: [], dagegen: [], enthalten: [] };\n  if (!passage) return lager;\n\n  const offen = [];\n\n  for (const [seite, frage] of LAGER_FRAGEN) {\n    const treffer = frage.exec(passage);\n    if (!treffer) continue;\n\n    const ab = passage.slice(treffer.index + treffer[0].length, treffer.index + treffer[0].length + 200);\n    const grenze = RE_LAGER_GRENZE.exec(ab);\n    const antwort = grenze ? ab.slice(0, grenze.index) : ab;\n\n    if (RE_LAGER_NIEMAND.test(antwort)) continue;\n    if (RE_LAGER_ALLE_ANDEREN.test(antwort)) { offen.push(seite); continue; }\n\n    const gefunden = new Set();\n    for (const m of antwort.matchAll(\n      /(CDU\\/CSU|Unionsfraktion(?:en)?|Union|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|fraktionslos)/gi\n    )) {\n      const f = kanonischeFraktion(m[1]);\n      if (f) gefunden.add(f);\n    }\n    lager[seite] = [...gefunden];\n  }\n\n  // \u201eAlle anderen\" hei\u00dft: alles, was noch nicht genannt wurde.\n  const genannt = new Set([...lager.dafuer, ...lager.dagegen, ...lager.enthalten]);\n  const imHaus = new Set(\n    [...passage.matchAll(/(CDU\\/CSU|Unionsfraktion|Union|SPD|AfD|B\u00dcNDNIS\\s?90\\/\\s?DIE GR\u00dcNEN|B\u00fcndnis\\s?90\\/\\s?Die Gr\u00fcnen|DIE LINKE|Die Linke|FDP|BSW|SSW)/gi)]\n      .map((m) => kanonischeFraktion(m[1]))\n      .filter(Boolean)\n  );\n  for (const seite of offen) {\n    lager[seite] = [...imHaus].filter((f) => !genannt.has(f));\n  }\n\n  return lager;\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  // Namen werden nur gesammelt, um daraus die Abweichler zu bestimmen; danach\n  // bleiben ausschliesslich die der kleinen Minderheitenseiten \u00fcbrig.\n  const namen = {};\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        namen[fraktion] = { ja: [], nein: [], enthalten: [], ungueltig: [] };\n      }\n      ergebnis.fraktionen[fraktion][abschnitt] += 1;\n      namen[fraktion][abschnitt].push(l);\n    }\n  }\n\n  if (ergebnis.abgegeben === null && !Object.keys(ergebnis.fraktionen).length) return null;\n  ergebnis.abweichler = findeAbweichler(ergebnis.fraktionen, namen);\n  return { ergebnis, endeIndex: i };\n}\n\n/** Ab wie vielen Abweichlern nur noch gez\u00e4hlt und nicht mehr namentlich gef\u00fchrt wird. */\nconst ABWEICHLER_MIT_NAMEN_BIS = 10;\n\n/**\n * Bestimmt je Fraktion, wer von der Linie abgewichen ist: alles, was nicht auf\n * der Mehrheitsseite der eigenen Fraktion steht.\n *\n * F\u00e4llt bei namentlichen Abstimmungen ohne Zusatzaufwand ab und ist genau die\n * Sorte Beobachtung, die einen Leser interessiert - in 21/90 etwa drei\n * Nein-Stimmen und vier Enthaltungen in der SPD bei 111 Ja.\n */\nfunction findeAbweichler(fraktionen, namen) {\n  const ergebnis = [];\n\n  for (const [fraktion, stimmen] of Object.entries(fraktionen)) {\n    const seiten = ['ja', 'nein', 'enthalten', 'ungueltig']\n      .map((art) => ({ art, anzahl: stimmen[art] || 0 }))\n      .filter((s) => s.anzahl > 0)\n      .sort((a, b) => b.anzahl - a.anzahl);\n\n    if (seiten.length < 2) continue;\n\n    const [mehrheit, ...minderheiten] = seiten;\n    // Eine Fraktion aus zwei Personen, die sich teilt, ist keine Abweichung.\n    if (mehrheit.anzahl < 3) continue;\n\n    ergebnis.push({\n      fraktion,\n      mehrheit,\n      abweichungen: minderheiten.map((s) => ({\n        ...s,\n        namen:\n          s.anzahl <= ABWEICHLER_MIT_NAMEN_BIS && namen[fraktion] ? namen[fraktion][s.art] : [],\n      })),\n      anzahl: minderheiten.reduce((summe, s) => summe + s.anzahl, 0),\n    });\n  }\n\n  return ergebnis;\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  const titel = baueTitel(relevant);\n  return {\n    titel,\n    bezeichnung: amtlicheBezeichnung(titel),\n    punkte,\n    drucksachen: [...drucksachen],\n    text,\n  };\n}\n\n/** Fraktionsnamen, wie sie in der Einbringer-Formel eines Antrags stehen. */\nconst FRAKTION_IN_FORMEL =\n  'CDU/CSU|SPD|AfD|B\u00dcNDNIS\\\\s?90/\\\\s?DIE GR\u00dcNEN|B\u00fcndnis\\\\s?90/\\\\s?Die Gr\u00fcnen|DIE LINKE|Die Linke|FDP|BSW|SSW';\n\n/** Kurzbezeichnung in Klammern am Ende: \"(GKV-Beitragssatzstabilisierungsgesetz)\". */\nconst RE_BEZ_KLAMMER = /\\(([^()]{4,90})\\)\\s*$/;\n/** Titel in Anf\u00fchrungszeichen: \u201eKultur macht stark. B\u00fcndnisse f\u00fcr Bildung\". */\nconst RE_BEZ_ZITAT = /[\u201e\u201c\"]([^\u201e\u201c\"]{6,160})[\u201c\"]/;\n/** Aktuelle Stunde: der Gegenstand steht hinter der verlangenden Fraktion. */\nconst RE_BEZ_VERLANGEN = new RegExp(\n  `auf Verlangen der Fraktion(?:en)? (?:der |des )?(?:${FRAKTION_IN_FORMEL})` +\n    `(?:\\\\s+und (?:der Fraktion )?(?:der )?(?:${FRAKTION_IN_FORMEL}))?[:\\\\s]+(.{6,200})$`\n);\n/** Antrag: der Gegenstand steht hinter der Einbringer-Formel. */\nconst RE_BEZ_NACH_FRAKTION = new RegExp(\n  `(?:und )?der Fraktion(?:en)? (?:der |des )?(?:${FRAKTION_IN_FORMEL})` +\n    `(?:\\\\s+und (?:der Fraktion )?(?:der )?(?:${FRAKTION_IN_FORMEL}))?\\\\s+(.{6,200})$`\n);\n/** Gesetze ohne Kurzbezeichnung tragen ihren Langtitel. */\nconst RE_BEZ_GESETZ = /(Gesetz(?:es)?\\s+(?:zur|zum|\u00fcber|f\u00fcr|betreffend)\\s+[^,(]{10,160})/;\n\n/**\n * Schneidet aus dem Vorspann eines Tagesordnungspunktes den Namen der Vorlage\n * heraus - das, was ein Leser als \u00dcberschrift erwartet.\n *\n * Der volle Titel lautet etwa \"Beratung des Antrags der Abgeordneten Marc\n * Bernhard, Leif-Erik Holm, weiterer Abgeordneter und der Fraktion der AfD\n * Deutschland braucht echte Reformen\". Der Name steht darin, aber hinter der\n * Einbringer-Formel. An 72 Tagesordnungspunkten aus acht Protokollen l\u00f6sen\n * sich 60 sauber auf; der Rest f\u00e4llt bewusst auf den vollen Titel zur\u00fcck -\n * eine \"Befragung der Bundesregierung\" hat schlicht keine Kurzbezeichnung.\n */\nfunction amtlicheBezeichnung(titel) {\n  if (!titel) return null;\n  const t = titel.replace(/\\s+/g, ' ').trim();\n\n  const klammer = RE_BEZ_KLAMMER.exec(t);\n  if (klammer && /gesetz|ordnung|abkommen|vertrag|[A-Z\u00c4\u00d6\u00dc]{2,}/.test(klammer[1])) {\n    return nominativ(klammer[1]);\n  }\n\n  const zitat = RE_BEZ_ZITAT.exec(t);\n  if (zitat) return zitat[1].trim();\n\n  for (const muster of [RE_BEZ_VERLANGEN, RE_BEZ_NACH_FRAKTION]) {\n    const m = muster.exec(t);\n    if (m) {\n      const rest = m[1].replace(/^[:\\s\u2013-]+/, '').trim();\n      if (rest.length > 5) return nominativ(rest);\n    }\n  }\n\n  const gesetz = RE_BEZ_GESETZ.exec(t);\n  if (gesetz) return nominativ(gesetz[1]);\n\n  const doppelpunkt = /:\\s*(.{8,200})$/.exec(t);\n  if (doppelpunkt) return doppelpunkt[1].trim();\n\n  return t;\n}\n\n/**\n * Der Langtitel steht im Protokoll im Genitiv (\"Entwurf eines Gesetzes zur\n * \u00c4nderung...\"), als \u00dcberschrift will man den Nominativ.\n */\nfunction nominativ(text) {\n  return text\n    .replace(/^Gesetzes\\s+(?=zur|zum|\u00fcber|f\u00fcr|betreffend)/, 'Gesetz ')\n    .replace(/\\s*\u2013\\s*$/, '')\n    .trim();\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// Hauptbeschluss eines Tagesordnungspunktes\n// ---------------------------------------------------------------------------\n\n/**\n * W\u00e4hlt den einen Beschluss, der den Tagesordnungspunkt tr\u00e4gt, und macht aus\n * dem Rest Randnotizen.\n *\n * Ein Tagesordnungspunkt bringt leicht ein Dutzend Abstimmungen hervor - \u00fcber\n * das Gesetz, \u00fcber sechs Oppositionsantr\u00e4ge, \u00fcber vier Entschlie\u00dfungsantr\u00e4ge.\n * F\u00fcr einen lesbaren Report z\u00e4hlt davon eine: die \u00fcber die Hauptvorlage.\n * Rangfolge ist Gesetzentwurf vor Antrag vor Beschlussempfehlung vor\n * \u00dcberweisung; bei Gleichstand gewinnt der Beschluss mit namentlicher\n * Abstimmung, sonst der erste.\n *\n * Setzt ausserdem `hatSachentscheidung`: Aktuelle Stunden, Befragungen und\n * Debatten, die nur in einer \u00dcberweisung enden, geh\u00f6ren nicht in die\n * Beschlussliste, sondern in die Kurzliste am Ende des Reports.\n */\nfunction waehleHauptbeschluss(top) {\n  const ausNamentlich = (top.abstimmungen || []).map((a) => ({\n    satz: a.verlesen || `Namentliche Abstimmung: ${a.ja} Ja, ${a.nein} Nein.`,\n    art: klassifiziereBeschluss(a.verlesen || '').art,\n    ergebnis: a.ergebnisText,\n    lager: null,\n    drucksachen: a.drucksachen || [],\n    namentlich: true,\n    abstimmung: a,\n  }));\n\n  const alle = [...ausNamentlich, ...top.beschluesse].filter(\n    (b) => b.art !== 'geschaeftsordnung' && b.ergebnis\n  );\n\n  // Wor\u00fcber der Punkt eigentlich geht, sagt sein Vorspann - nicht der\n  // Abstimmungssatz. Ein versp\u00e4tet verlesenes Ergebnis nennt oft ein ganz\n  // anderes Thema und w\u00fcrde einen AfD-Antrag sonst zum Gesetzentwurf machen.\n  top.vorlagenart =\n    top.key === 'Er\u00f6ffnung' ? 'geschaeftsordnung' : klassifiziereBeschluss(top.titel || '').art;\n\n  const rang = (b) => {\n    const i = HAUPTBESCHLUSS_RANG.indexOf(b.art);\n    return i < 0 ? HAUPTBESCHLUSS_RANG.length : i;\n  };\n  const nachRang = (a, b) => rang(a) - rang(b) || Number(b.namentlich) - Number(a.namentlich);\n\n  const passend = alle.filter((b) => b.art === top.vorlagenart).sort(nachRang);\n  const uebrige = alle.filter((b) => b.art !== top.vorlagenart).sort(nachRang);\n\n  // Passt nichts zur Vorlagenart, kommt nur ein verwandter Beschluss in Frage:\n  // \u00fcber einen Antrag wird formal per Beschlussempfehlung entschieden, \u00fcber\n  // eine erste Beratung per \u00dcberweisung. Ein Gesetzentwurf-Ergebnis in einem\n  // Antrags-Punkt ist dagegen immer ein fremdes, hier nur verlesenes Ergebnis.\n  const ersatz =\n    top.vorlagenart === 'sonstiges'\n      ? uebrige[0]\n      : uebrige.find((b) => !b.namentlich && VERWANDTE_ARTEN[top.vorlagenart]?.includes(b.art));\n\n  top.hauptbeschluss = passend[0] || ersatz || null;\n  top.nebenbeschluesse = [...passend.slice(1), ...uebrige].filter((b) => b !== top.hauptbeschluss);\n  top.hatSachentscheidung = Boolean(\n    top.hauptbeschluss &&\n      top.hauptbeschluss.art !== 'ueberweisung' &&\n      top.vorlagenart !== 'geschaeftsordnung' &&\n      !top.gebuendelt\n  );\n\n  return top;\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      bezeichnung: 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    bezeichnung: '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            // Der Absatz kommt mit: das Fraktionsverhalten steht in der\n            // Frage-Antwort-Form davor (\"Wer stimmt daf\u00fcr? \u2013 CDU/CSU und SPD\"),\n            // nicht im Ergebnissatz selbst.\n            beschlussTop.beschluesse.push({\n              satz,\n              ...klassifiziereBeschluss(satz),\n              lager: extrahiereLager(absatz),\n              drucksachen: [...new Set(satz.match(RE_DRUCKSACHE_NR) || [])],\n              namentlich: false,\n            });\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) {\n          neuerTop.titel = antrag.titel;\n          neuerTop.bezeichnung = antrag.bezeichnung;\n        }\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 (!woerte
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

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

HTTP Request, Chain Llm, Read Write File +1
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