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 →
{
"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
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 →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
Bundestag: Plenarprotokoll parsen und mit Ollama zusammenfassen. Uses httpRequest, chainLlm, readWriteFile, lmChatOllama. Event-driven trigger; 27 nodes.
Episode 11: AI shorts factory app. Uses httpRequest, googleSheets, lmChatOpenAi, lmChatOllama. Event-driven trigger; 96 nodes.
My workflow 53. Uses formTrigger, httpRequest, lmChatOpenAi, form. Event-driven trigger; 74 nodes.
Episode 23: UGC with nanobanana. Uses lmChatOpenAi, lmChatOllama, lmChatDeepSeek, lmChatOpenRouter. Event-driven trigger; 74 nodes.
🤖🧑💻 AI Agent for Top n8n Creators Leaderboard Reporting. Uses httpRequest, lmChatOpenAi, executeWorkflowTrigger, toolWorkflow. Event-driven trigger; 49 nodes.