Zum Inhalt springen

KI-Pipeline

Dieses Dokument beschreibt die KI-gestützten Schritte rund um das Ratsinformationssystem (Oldenburger Stadtrat): Ausschuss-Zusammenfassungen, Themen-Matching (Watcher) und die Beschluss-Aufbereitung. Alle LLM-Aufrufe laufen über OpenRouter (siehe ADR 0001).

Die Pipeline-Qualität entscheidet über den Produktwert: Sie bestimmt, wie zuverlässig relevante Tagesordnungspunkte und Beschlüsse zu den Themen der Nutzer*innen gefunden werden — bei hoher Precision (keine Fehltreffer) und brauchbarem Recall.

Wichtig vorab: Das Scraping selbst nutzt kein LLM. Tagesordnungen und Protokolle werden per BeautifulSoup bzw. pypdf strukturiert eingelesen; das LLM kommt erst beim Zusammenfassen, Matchen und Klassifizieren zum Einsatz. Vorlagen und Anlagen werden sogar komplett ohne LLM ausgewertet.

flowchart TD
  subgraph Quellen["Gescrapte Quellen — ohne LLM eingelesen"]
      AG["Tagesordnungen"]
      PR["Sitzungsprotokolle (PDF)"]
      VO["Vorlagen & Anlagen (PDF)"]
  end

  AG --> ZSF["LLM: Sitzungs-Zusammenfassung
  je Ausschuss"]
  AG --> MATCH["LLM: Matching gegen
  Nutzer-Themen"]
  PR --> EXTR["LLM: Beschluss-Extraktion
  (ein Aufruf je Protokoll)"]
  EXTR --> KLASS["LLM: Themenfeld-
  Klassifikation"]
  KLASS --> ENRICH["LLM & Embeddings (wöchentlich):
  Entitäten · Geocoding · Ähnliche · Rückblicke"]
  VO -->|"pypdf + Wortlisten,
  ganz ohne LLM"| TEXTE["Volltexte &
  Antragsteller"]

  ZSF --> N["Benachrichtigungen
  E-Mail · Push"]
  MATCH --> N
  KLASS --> F1["Filter · Analyse ·
  Karte · Trends"]
  TEXTE --> F2["Beschluss-Seiten ·
  Suche · KI-Frage"]
Pipeline Quelle Rhythmus
Ausschuss-Zusammenfassung Tagesordnungen täglich
Watcher (Themen-Matching) Tagesordnungen mehrmals täglich
Protokoll-Extraktion + Beschluss-Klassifikation Sitzungsprotokolle täglich
„Lotti erklärt’s einfach“ (bürgernahe Kurzfassung) Beschlusstext täglich (neue), wöchentlich (Bestand)
Bewertungs-Scores (Tragweite, Gesprächswert) Beschlusstext täglich (neue), wöchentlich (Tranchen)
Fundstück des Tages (kuratierte Archivfunde) bewertete Beschlüsse wöchentlich, 21 Tage Vorlauf
Anreicherung (Entitäten, Embeddings, Rückblicke) Beschlüsse wöchentlich
Quiz-Fragen (Generierung + Verify-Pass) Wikipedia · Stadt-Website · Ratsdaten Backfill (manuell + wöchentlich)
KI-Frage „Frag den Rat“ (Ratsgespräch) Beschlüsse + Vorlagen-Chunks + Stadt-Pressemitteilungen + laufende Bauleitplan-Beteiligungen + bei Geldfragen der Haushalts-Bestand; Anschlussfragen mit Gesprächskontext auf Zuruf (Streaming)

Tragweite, Gesprächswert und der daraus gemischte Wichtigkeits-Wert haben eine eigene Seite: Bewertungs-Scores.

  • Ausschussliste aktualisieren, kommende Sitzungen (3 Monate voraus) scannen.
  • Pro Sitzung mit Tagesordnung wird ein Hash der Agenda gebildet — er dient als Cache-Schlüssel und zur Änderungserkennung (neue vs. geänderte Tagesordnung).
  • Routine-TOPs („Genehmigung der Niederschrift“, Fragestunde …) filtert bereits der Code vor, erst dann fasst das LLM zusammen (JSON-Mode). Das Ergebnis wird je Agenda-Stand gecacht — dieselbe Tagesordnung wird nie zweimal zusammengefasst.
  • Pro Konto werden nur zukünftige Sitzungen mit Tagesordnung klassifiziert, die dieses Konto noch nicht gesehen hat — ändert sich eine Tagesordnung, wird erneut geprüft und benachrichtigt.
  • Das LLM bekommt die öffentlichen TOPs und die nummerierte Themenliste des Kontos und liefert die Treffer-Paare als JSON. Alerts werden dedupliziert.
  • Der Prompt liegt zentral in kern/prompts.py.

Neu veröffentlichte Sitzungsprotokolle werden geparst, Beschlüsse extrahiert (ein LLM-Aufruf je Protokoll) und je Beschluss per LLM in Themenfelder eingeordnet — die Grundlage für Filter, Analyse und die Themenfeld-Rückblicke. Details zu den Dokumenten und dem Datenmodell: Ratsdokumente & Beschlüsse.

Orte werden ähnlich wie Personen und Themen in zwei Schichten behandelt. Die Extraktion speichert zunächst den konkreten Namen und seine Fundstelle im Beschluss. Straßenmuster und Namen aus dem zentralen Ortskatalog erkennt der Code deterministisch; ein LLM ergänzt Gebäude und Gebiete, darf aber nur wörtlich belegte Fundstellen liefern. Geocoding ordnet freie Beobachtungen anschließend, sofern möglich, einem der 31 flächendeckenden Ortsbereiche zu.

Der zentrale Ortskatalog enthält die kuratierten Entitäten: neben den Ortsbereichen auch ausgewählte Quartiere, Parks, Schutz-, Wohn-, Sanierungs- und Entwicklungsgebiete. Jeder Eintrag hat eine stabile ID, einen Typ, Aliase, Quellen und optional Elternorte sowie Koordinaten. Neue Ortslinks erhalten place_id und ortsbereich_id; ein Backfill ergänzt sie im Bestand.

Vier Regeln, die dabei aussieben (Prüfung des Bestands, 09/2026)

Abschnitt betitelt „Vier Regeln, die dabei aussieben (Prüfung des Bestands, 09/2026)“

Ein Abgleich der echten Zuordnungen legte vier Fehlerklassen offen. Alle vier sind jetzt Code, keine davon fällt in einer frischen Testdatenbank auf:

  1. Der Verlauf schlägt den Punkt. Der Ortsbereich wurde aus dem Mittelpunkt des umschließenden Rechtecks abgeleitet. Bei Flächen trägt das, bei Straßen nicht: Eine Straße mit einem Knick hat diesen Punkt neben sich. „Alter Postweg“ verläuft vollständig in Kreyenbrück und lag in keinem Ortsbereich. geo.ortsbereich_der_geometrie stimmt jetzt über Stützpunkte der ganzen Geometrie ab; der Punkt bleibt der Rückfall ohne Geometrie.

  2. Der Name zählt, wenn er eindeutig ist. „Oberschule Ofenerdiek“, „GS Drielake“ — kein Geocoder kennt sie, 71 Orte mit 176 Verweisen blieben deshalb ohne Stadtteil. backfill_location_districts_from_name liest ihn aus dem Namen, aber nur bei einem Treffer: „Entlastungsstraße Fliegerhorst-Wechloy“ bleibt lieber ohne Zuordnung als mit einer geratenen.

  3. Stadtweites bleibt stadtweit. 110 Vorgänge, die dem Titel nach die ganze Stadt betreffen, trugen einen Stadtteil aus einer Nebenerwähnung im Vorlagentext — der „Oldenburg Pass“ an der Anschrift des Verkehrsbetriebs. locations.ortsbezug_ist_beiwerk verwirft solche Funde, aber nur wenn drei Dinge zusammenkommen: stadtweite Vokabel im Titel, kein Ort im Titel, Fund aus dem Vorlagentext. Deshalb bleibt „Masterplan Fliegerhorst“ unangetastet — auch im Genitiv („Unterschutzstellung des Heidbrooks“).

  4. Gattungsbegriffe sind keine Orte. „Gemeindestraße“ (51 Verweise), „Entlastungsstraße“ (38), „Kunstrasenplatz“ (19), „Radweg“ (13), „Monitoring“ — der Gattungsfilter hing bis dahin nur am Regex-Kanal, über das Modell kamen sie ungehindert durch. Nur die bloße Form ist gesperrt.

  5. Fremde Straßen werden abgeschnitten. overpass_street sucht Wege eines Namens in einem RECHTECK um Oldenburg, und das ist größer als die Stadt: Bei häufigen Namen kam der gleichnamige Weg der Nachbargemeinde mit, alle Segmente wurden verschmolzen, und der Mittelpunkt lag dazwischen — in keinem Ortsbereich. geo.auf_stadtgebiet_beschneiden wirft die fremden Segmente weg und rechnet den Mittelpunkt neu; liegt gar nichts in der Stadt, ist der Treffer keiner. Die Polygone gehören uns, das Rechteck war nur eine Vorauswahl.

Zwei Fallen, die beim Prüflauf selbst zuschlugen und deshalb Code geworden sind: Ein misslungenes Geocoding darf keinen Stadtteil löschen (ein Fehlschlag weiß nichts) — ein Treffer außerhalb Oldenburgs dagegen schon (der weiß etwas). Und fehlen die Ortsbereichs-Polygone, liefert jede Geo-Ableitung still None; der Lauf sieht erfolgreich aus und ordnet nichts zu. geo._features() schreibt das jetzt als Warnung ins Log.

Ein weiterer Fund gehört nicht in die Qualität, sondern in den Datenschutz: Fundstellen, die wie eine Wohnanschrift aussehen (… 49, 26127 Oldenburg), werden abgewiesen. Sie stammten aus Vorlagen zu Ausschussbesetzungen und verorteten Personalbeschlüsse nach der Privatadresse eines Mitglieds.

Bestand nachziehen — beides ohne --apply erst als Bericht:

Terminal-Fenster
.venv/bin/python scripts/geocode_decision_locations.py --erneut
.venv/bin/python scripts/revalidate_decision_locations.py --apply

Der erste Lauf holt Orte nach, bei denen das Geocoding schon einmal misslungen ist (Overpass und Nominatim antworten nicht jeden Tag gleich) und leitet danach Stadtteile neu ab. Der zweite wendet die Regeln 3 und 4 auf den Bestand an.

Häufige, noch unbekannte Gebietsnamen landen mit ihren Fundstellen in der Admin-Prüfung. Freigaben oder Alias-Zuordnungen erweitern den Laufzeitkatalog sofort und werden beim nächsten Beschlussimport deterministisch erkannt; Verwerfungen blenden unzuverlässige Namen aus öffentlichen Karten aus.

Das wirkt in allen Produkten gleich: Die Beschlusssuche filtert über die stabile ID, Ortsprofile zeigen Stammdaten und belegte Beschlüsse, und das Quiz verwendet dieselben Namen und Quellen. Erkennt „Frag den Rat“ einen Katalogort in der Frage, begrenzt es semantische und lexikalische Treffer auf die direkt diesem Ort zugeordneten Beschlüsse. Die generierte Antwort darf den Ortsbezug nur aus den mitgelieferten Fundstellen ableiten.

Auf der Stadtkarte erscheinen außerdem konkrete, geocodierte Beschlussorte. Für automatisch erkannte Straßen, Plätze, Gebäude und Gewässer gilt eine Mindestbelegung von drei Beschlüssen; unscharfe Gebiete benötigen vorher eine redaktionelle Freigabe. Kartenpunkte führen auf das Ortsprofil oder auf eine exakte Beschluss-Suche für diesen Ort.

Amtsdeutsch ist die größte Hürde vor den Ratsdaten. Deshalb schreibt council/simple_summary.py je Beschluss zwei bis drei bürgernahe Sätze in die Spalte simple_summary — was beschlossen wurde und was es praktisch bedeutet, ohne Fachbegriffe.

Zwei Einschränkungen sind bewusst gesetzt: Nur echte Beschlüsse (kind='decision') mit einem hinreichend langen Beschlusstext bekommen eine Kurzfassung — bei einer Zwei-Zeilen-Kenntnisnahme gäbe es nichts zu vereinfachen, und eine erfundene Erklärung wäre schlimmer als keine. Neue Beschlüsse werden im täglichen Protokoll-Lauf bedient, der Bestand wandert in wöchentlichen Tranchen nach.

Zieht die schwereren LLM-/Embedding-Backfills nach, damit Themen-Seiten, Karte und „Ähnliche Beschlüsse“ frisch bleiben: Entitäten-Extraktion → Beschreibungen → Geocoding → Embeddings/Ähnliche → Themen↔Beschlüsse-Matching → Themenfeld-Rückblicke.

Bestätigte Themen-Zusammenführungen (siehe unten) wendet der Entitäten-Schritt dabei automatisch mit an. Die Suche nach neuen Dubletten läuft bewusst nicht wöchentlich mit, sondern nur auf Anstoß („Ops: Themen-Dubletten zusammenführen“) — eine Zusammenführung sollte man sehen, bevor sie wirkt.

Die Entitäten-Extraktion entscheidet je Beschluss-Batch neu, wie sie eine Sache benennt, und kennt den übrigen Bestand nicht. Über Jahre entstehen so mehrere Themen-Seiten für denselben Gegenstand — gemessen am Bestand vom 23.07.2026: 23 Gruppen mit 50 Namen, darunter vier für den Bäderbetrieb und drei für die Gebäudewirtschaft. Beschlüsse und aufsummierte Beträge verteilen sich dann auf mehrere Seiten.

scripts/merge_entity_aliases.py räumt das auf. Erkennungsmerkmal ist kontraintuitiv, aber zuverlässig: echte Dubletten haben null gemeinsame Beschlüsse bei sehr hoher inhaltlicher Nähe. Das folgt direkt aus der Ursache — nennt die Extraktion in einem Beschluss „Bäderbetrieb Oldenburg“, nennt sie dort nicht zusätzlich „Bäderbetrieb der Stadt Oldenburg“.

Der Lauf sammelt Kandidaten über Namensnormalisierung (Rechtsform, Ortszusatz, vorangestellte Gattung, Abkürzung, Teilstring) und legt jedes Paar einzeln dem LLM vor — mit Beschlusstiteln als Beleg. Nötig, weil Namensähnlichkeit allein nicht reicht: „IBIS“/„IBIS e.V.“ ist eine Sache, „Fliegerhorst“/„Grundschule Fliegerhorst“ sind zwei. Bei Abkürzungen erzwingt der Code die Langform als Hauptnamen; wer „VWG“ sucht, landet über die Zuordnung trotzdem richtig.

Die Zusammenführung ist umkehrbar: Geschrieben wird nur die Zuordnungstabelle council_entity_aliases. Die Roh-Beobachtungen bleiben unangetastet, und die Themen werden bei jedem Lauf neu daraus abgeleitet — eine Zuordnung löschen (Admin-Panel → „Themen-Dubletten“) stellt den vorherigen Stand her. Von Hand gesetzte Zuordnungen überschreibt ein automatischer Lauf nie.

Das Oldenburg-Quiz erzeugt seine Multiple-Choice-Fragen per LLM, aber streng geerdet. Pro Ort (Ratslotse-Ortsbereich oder kuratierter Katalogort), Wahlbereich oder stadtweitem Thema sammelt der Code zuerst Quelltexte — Wikipedia (Stadtteil-Artikel), die Stadt-Website oldenburg.de und die eigenen Ratsdaten — und legt sie dem LLM vor. Das Modell formuliert daraus Fragen mit vier Antwortoptionen, Erklärung, Kategorie und Quellenangabe; es erfindet nichts frei.

Ein zweiter, günstiger Verify-Pass (kleines Modell) verwirft Fragen, die nicht eindeutig aus der Quelle belegbar sind. Antwortoptionen werden gemischt (die richtige Antwort liegt sonst gern auf Position A), Dubletten fängt ein content_hash.

Danach kommt der Richter (seit 09/2026, quiz.rate_appeal): Er fragt nicht, ob eine Frage stimmt, sondern ob man sie gern spielt, und benotet sie von 1 bis 5. Unter 3 wird eine neue Frage gar nicht erst gespeichert; im Bestand kommen knappe Fälle in der Runde erst nach den reizvollen dran und nie in die Tages-Challenge. Umformulierungen einer schon vorhandenen Frage fallen über einen Wortvergleich heraus, und Ratspolitik macht höchstens ein Drittel einer Lieferung aus. Anlass: Im Bestand waren 48 % der Fragen Ratspolitik, und viele davon fragten Straßenabschnitte oder Aufstellungsbeschlüsse ab, obwohl der Erzeugungs-Prompt genau das seit Juli verbietet. Den Bestand räumt scripts/sweep_quiz.py auf (Trockenlauf ohne --retire; alle rund 700 Fragen kosten gemessen $0,03).

Zusätzlich bewerten Nutzer*innen jede Frage nach dem Beantworten (👍/👎) — schlecht bewertete erscheinen in einer Admin-Liste und werden ausgemustert; da der Backfill nur Gebiete unter der Ziel-Fragenzahl auffüllt, erzeugt der nächste Lauf automatisch Ersatz.

Die Fragen liegen in council.sqlite (generierter, regenerierbarer Inhalt), Punkte und Bewertungen in ratslotse.sqlite (pro Konto). Der Backfill (scripts/generate_quiz.py) läuft als Auffüll-Schritt der wöchentlichen Anreicherung und lässt sich für den Erstlauf manuell über einen Ops-Workflow anstoßen.

Das Karten-Quiz („Wo liegt Ortsbereich X?“) ist die Ausnahme: rein geografisch, ganz ohne LLM — die Fragen entstehen deterministisch aus den Ortsbereich-Polygonen, die Karte selbst ist die Antwortfläche. Der gemeinsame Ortskatalog dokumentiert, warum diese 31 Ratslotse-Gebiete keine amtlich festgelegten Stadtteile sind.

Auch die Haushalts-Fragen (Thema „Stadt-Haushalt“) entstehen ohne LLM: council/haushalt.py parst die „Übersicht Ergebnishaushalt“ aus den beschlossenen Haushaltsplan-PDFs der Stadt (oldenburg.de, aktuelles Jahr plus Archiv ab 2020; das 2024er-PDF hat eine defekte Text-Kodierung und wird übersprungen), validiert die Teilhaushalts-Zeilen gegen die Summenzeile und baut daraus Schätz- und Multiple-Choice-Fragen per Template — jede Zahl stammt 1:1 aus dem Plan, jede Frage verlinkt das PDF als Quelle. Die Auflösung liefert je nach Frage ein Balkendiagramm (Aufwendungen/Erträge je Teilhaushalt), einen Donut (Anteil an den Gesamtausgaben) oder eine Trendlinie über die Haushaltsjahre mit (JSON in der Frage, gerendert im Frontend) und erklärt die Zusammensetzung (Pflichtaufgaben vs. freiwillige Leistungen — beides im Glossar). Eingelesen wird einmal je Haushaltsjahr (scripts/ingest_haushalt.py); ein Re-Ingest frischt bestehende Fragen auf (z. B. verlängert er die Trendlinie um neue Jahre).

Reichere Antworten: Zu jeder Frage kann die Auflösung optional eine ausführlichere Erklärung, eine kleine Locator-Karte und ein Foto zeigen. Das LLM nennt dazu ein „subject“ (das zentrale reale Ding, z. B. „Schloss Oldenburg“), das Backend reichert best-effort an: Koordinaten per Nominatim (auf Oldenburg begrenzt) und ein Foto vom Wikimedia Commons — über das Lead-Bild des Wikipedia-Artikels. Genutzt werden nur frei lizenzierte Bilder (CC/PD; Fair-Use fliegt raus), immer mit Bildnachweis (Autor, Lizenz, Link) direkt aus der Commons-API.

Bei der Karte hat Verlässlichkeit Vorrang, in absteigender Genauigkeit: Für eindeutige, kompakte Straßen zeichnet das Backend die echte Linie (OpenStreetMap/Overpass), aber nur wenn die Geometrie klein genug ist — verstreute oder mehrfach vergebene Straßennamen (mehrere „Mittelwege“) werden verworfen, damit nie eine falsche Stelle markiert wird. Einen Punkt-Marker gibt es sonst nur für bekannte Einzelorte (die einen Wikipedia-Artikel haben). Und geht es um einen ganzen Ortsbereich (oder eine Person/Sache von dort), zeichnet die Karte das ganze Gebiet als Polygon ein — die Ratslotse-Grenzen besitzen wir selbst, das ist also immer verlässlich. Bei Fragen zu einer konkreten Person oder Sache verweist der Quelle-Link außerdem auf deren eigenen Wikipedia-Artikel statt auf die Gebiets-Seite. Zu kniffligen Fragen kann es einen optionalen Tipp geben (vor dem Auflösen einblendbar), und Fachbegriffe in der Erklärung (z. B. „Vergnügungsstätte“, „Bebauungsplan“) unterlegt das Frontend mit einem Hover-Glossar.

Frag den Rat: wenn die Frage keinen Gegenstand nennt

Abschnitt betitelt „Frag den Rat: wenn die Frage keinen Gegenstand nennt“

„Was hast du?“ bekam am 10.09.2026 eine ordentlich belegte Auskunft darüber, woran der Stadtrat gerade arbeitet. Die Pipeline konnte gar nicht anders: Die Suche findet immer etwas, der Reranker sortiert es, und das Antwort-Modell sieht zwanzig Beschlüsse und eine Frage — es schreibt daraus etwas Plausibles. Nur hatte niemand danach gefragt. Eine Antwort auf eine nicht gestellte Frage ist teurer als keine Antwort: Sie sieht richtig aus.

Seit 09/2026 urteilt der Analyse-Call deshalb mit, ob die Frage überhaupt einen Gegenstand nennt (Feld unklar, council/qa.py). Der Router fragt dann zurück, statt zu antworten — mit qa.RUECKFRAGE_TEXT und, über das vorhandene suggestions-Ereignis, mit konkreten Fragen zum Weitermachen (qa.rueckfrage_vorschlaege: der Titel-Anker der Frage, sonst die jüngsten Sitzungen mit Beschlüssen — nichts Erfundenes).

Zwei Dinge halten den teureren der beiden Fehler klein, nämlich eine beantwortbare Frage abzuweisen:

  • Die deterministische Erkennung überstimmt das Modell. Wer eine Ratsperson, einen Katalogort oder eine konkrete Sitzung nennt, hat einen Gegenstand genannt — dann wird gesucht, egal was das Urteil sagt. „Einfacher erklären“ ist aus demselben Grund ausgenommen: Der Knopf schickt einen Wunsch, keine Frage.
  • Der Prompt sagt „im Zweifel false“. Gemessen an den 30 Gold-Fragen der Routing-Suite und an den 26 kuratierten Beispielfragen des Empty States wurde keine einzige abgewiesen; die Suite führt die Klarheit seither als eigene Spalte (clarity, eval/run_qa_routing.py).

Der Kurzschluss steht vor der Suche: Retrieval, Reranker, Haushalts-Bausteine und Antwort-Modell kosten zusammen ein Vielfaches des einen Analyse-Calls, der das Urteil ohnehin schon mitbringt. Gezählt wird die Rückfrage getrennt (ai_question_unclear) — ai_answer_empty misst Fragen, die nichts gefunden haben; hier wurde gar nicht erst gesucht.

„Was ist eine Ausfallbürgschaft?“ beantwortete die KI-Frage bis 09/2026 mal so und mal gar nicht — je nachdem, ob unter den gefundenen Beschlüssen zufällig einer war, der den Begriff nebenbei miterklärt (beides gemessen, dieselbe Frage, 04.09.2026). Der Grund war keine Lücke im Modell, sondern eine im Prompt: Die Erklärungen lagen als Frontend-Liste vor und erreichten das Modell nie.

Sie stehen jetzt in kern/glossar.py — wie die Prompts als Code, nicht als Datenbankinhalt. web/frontend/lib/glossary.ts wird daraus erzeugt (python scripts/glossar_ts.py), und scripts/pruefe.py hält beide gegeneinander; zwei handgepflegte Listen wären lautlos auseinandergelaufen.

Gematcht wird deterministisch, ohne LLM: Wortanfang plus beliebige Buchstaben-Endung. Damit greifen Beugungen („Bebauungsplans“), aber keine Komposita, die den Begriff hinten tragen — „Bürgschaft“ trifft „Ausfallbürgschaft“ nicht, die ist deshalb ein eigener Eintrag. Genau daran hing der Ausfall.

Drei Wege nutzen den Bestand:

Wo Was daraus wird
Antwort-Prompt (qa._glossar_block) Höchstens drei Begriffe aus der Frage, als Block „Was die Fachwörter bedeuten“ — ausdrücklich keine Beschlüsse und deshalb nie mit [id] zitiert. Der Ehrlichkeits-Vorbehalt („die Beschlüsse geben nichts her“) ist für die Definition aufgehoben, für die Beschlusslage nicht
„Einfacher erklären“ (qa._glossar_block_einfach) Bis zu fünf Begriffe, gezogen aus der vorigen Antwort — die Bitte selbst nennt ja keins. Der Prompt verlangt „kein Fachwort ohne Erklärung im selben Satz“; die Erklärung kommt jetzt aus dem Bestand statt aus dem Bauchgefühl
Antworttext im Frontend Jeder Begriff wird bei seiner ersten Nennung gepunktet unterstrichen und zeigt die Erklärung beim Überfahren oder Antippen (markiereBegriffe, geteilt mit dem Hover-Glossar der Haushalts-Seiten)

Die Auswahl ist gemessen, nicht geraten: Ein neuer Begriff muss in den Unterlagen wirklich vorkommen. Die 52 Ergänzungen vom 04.09.2026 stammen aus einer Auszählung über 9.059 Beschlüsse und 4.000 Vorlagen — Spitzenreiter waren Aufwandsspaltung (186), Teileinrichtung (178) und Leitantrag (212), Wörter, die außerhalb des Straßenausbaubeitragsrechts und des Sitzungssaals niemandem begegnen.

Ein Knopf auf jeder Seite öffnet ein Fenster, in dem Lotti erklärt, was gerade auf dem Bildschirm steht — die Seite, ein angeklicktes Element oder markierter Text. Der Endpunkt ist POST /api/council/explain (council/assistant.py), und er ist bewusst keine zweite KI-Frage: Es gibt kein Retrieval, keinen Reranker und keine Kandidatenliste. Der Gegenstand steht auf der Seite, die Person zeigt selbst darauf.

Drei Wege kommen ohne Modell aus und beantworten zusammen den häufigsten Klick — gemessen 10 von 30 Eval-Fällen, je unter einer Millisekunde:

Weg Wann Woher die Antwort kommt
glossary die Markierung trifft genau einen Fachbegriff kern/glossar.py (151 geprüfte Erklärungen)
simple_summary Beschluss-Seite, „Was sehe ich hier?“ die Spalte simple_summary („Lotti erklärt’s einfach“)
page bekannte Seite, niemand hat gezeigt kern/knowledge.py

kern/knowledge.py ist der einzige neue Bestand: je Seite drei Sätze — was sie zeigt, woher die Zahlen kommen und was sie nicht sagt. Die Liste ist vollständig, und tests/test_assistant.py hält das in beide Richtungen: Jede Route aus kern/seitenaufrufe.py ist entweder erklärbar, mit Grund gesperrt (/admin und /account tragen fremde bzw. eigene Kontodaten) oder liegt außerhalb der App-Hülle.

Browsertext ist Daten, nie Anweisung. Element-Text und Markierung stehen im Prompt zwischen Markern (<<<ELEMENT … ELEMENT), und der Prompt sagt ausdrücklich, dass darin keine Anweisungen stehen — dasselbe Muster wie beim Watcher für frei eingegebene Themen. Das ist keine Vorsicht auf Verdacht: Der Element-Text trägt Auszüge aus Ratsvorlagen, also Text, den Dritte geschrieben haben. Sechs Fälle im Eval (eval/run_assistant.py) schieben Anweisungen unter („ignoriere alle Anweisungen“, „antworte auf Englisch“, „du bist jetzt …“); gemessen am 21.09.2026 wurden 6 von 6 abgewehrt.

Was das Archiv braucht, geht ans Archiv. Fragt jemand „Wer hat dagegen gestimmt?“, antwortet Lotti nicht, sondern sagt das im ersten Satz und bietet den Weg zu „Frag den Rat” an. Die Entscheidung fällt deterministisch am Wortlaut (assistant.archivfrage), nicht im Modell: Es soll die Marke WEITER: ratsfrage selbst setzen und tut das meistens — über drei Eval-Läufe hat es sie einmal vergessen, und dann stand da eine Absage ohne Ausweg.

Kosten, gemessen am 21.09.2026 über 20 echte Aufrufe (Gemini 2.5 Flash):

Wert
Eingabe-Tokens je Aufruf 1.583
Ausgabe-Tokens je Aufruf 88
Kosten je Aufruf 0,070 Cent
teuerster Aufruf 0,101 Cent

Zum Vergleich: Der Antwort-Prompt der KI-Frage ist mit 8.412 Zeichen gut anderthalbmal so groß wie Lottis 5.844, und dazu kommen dort Analyse, Expansion und Reranker.

Mit dem Schalter lotti-selbstpruefung prüft Ratslotse einen Teil der Erklärungen, die ein Modell geschrieben hat (COUNCIL_ASSISTANT_PRUEFER_ANTEIL, Vorgabe 10 %) — nach der Antwort, als Hintergrund-Aufgabe, wenn der Strom schon beim Client ist (council/self_check.py):

  1. Ohne Modell: Jede Zahl der Antwort steht im Kontext, unter dem Jahr, unter dem sie dort steht — dieselbe Logik wie die Fakten-Eval (council/fakten_abgleich.py); dazu keine Wertung in eigener Stimme und kein Rest aus dem Prompt.
  2. Prüfer-Modell einer anderen Familie als die Antwort (Gemini gegen GPT-6 Luna), mit Zero Data Retention. Es bekommt Lottis ganzen Prompt samt Frage und Antwort und urteilt als JSON.

Das Urteil landet in assistant_checks und im Admin-Reiter „Lotti“ — Frage und Antwort nur mit Einwilligung in die Gesprächsspeicherung.

Warum nicht vor der Anzeige? Gemessen am 24.09.2026 an der Fakten-Eval (178 Fälle): Die Fassung, die vor der Anzeige prüfte und bei einem Mangel neu schrieb, ersetzte 4 Antworten, eine davon besser — ok blieb bei 163, dafür +1,7 s im Median und das 2,5-Fache der Kosten. Zahlen und Befehle: docs/lotti-selbstpruefung.md.

Die KI-Frage antwortet nicht nur aus Beschlüssen. Zu Geldfragen liegen vierzehn weitere Quellen bereit — der ganze Haushalts-Bestand. Alle anzuhängen wäre die bequeme und die falsche Lösung: Jeder Baustein kostet Platz im Antwort-Prompt, in dem schon 20 Beschlüsse, Debatten-Auszüge, Pressemitteilungen und Steckbriefe stehen.

Welche Quelle gefragt wird, entscheidet qa.geld_facetten am Frage-Wortlaut — deterministisch, ohne LLM-Call:

Facette Quelle Beantwortet Bestand endet
schulden council_debt „Wie viel Schulden hat Oldenburg?“ (Bestand am Stichtag) 2025
stellenplan council_staff_plan „Wie viele Stellen sind unbesetzt?“ Planjahr
investitionen council_investments „Was wird gebaut?“ (Finanzhaushalt) Planjahr
antraege council_decisions (subvote) „Wer wollte den Haushalt ändern?“ letzter Jahrgang
plan council_budget „Was kostet die Feuerwehr?“ (Teilhaushalt) Planjahr
ansatz council_income_budget „Was nimmt die Stadt ein?“ (Ertrags-/Aufwandsarten) Planjahr
ist council_income_statement „Hat die Stadt mehr ausgegeben als geplant?“ 2024
gruende council_variance_reasons „Warum kam mehr Gewerbesteuer rein?“ 2024
produkte council_products „Muss die Stadt das Theater betreiben?“ (Rechtsgrundlage) 2023
pruefung council_audit_reports „Was hat das Rechnungsprüfungsamt beanstandet?“ 2023
konzern council_konzern_* „Was kostet die Stadt insgesamt?“ (1.242 statt 799 Mio.) 2024
vergleich council_city_comparison „Wie steht Oldenburg da?“ LSN-Jahrgang
steuern council_taxes „Wie hoch waren die Steuereinnahmen?“ (Ist) Open-Data-Jahrgang
ausgleich council_tax_capacity „Was bringt eine Hebesatz-Erhöhung wirklich?“ Ausgleichsjahr

Die vier oberen kamen am 17.08. dazu, und drei von ihnen mussten dafür einer Quelle weggenommen werden: „Schulden“ und „Investitionen“ standen im Muster für plan und zogen damit den Ergebnishaushalt — in dem der Schuldenstand nicht vorkommt und keine einzige Investition steht. Gemessen an 20 typischen Fragen zu diesen vier Schichten: vorher 0 Treffer, nachher 20.

Jeder der vier Bausteine trägt außerdem eine Grenze, die in die Antwort gehört und ohne die er schadet:

  • Schulden sind ein Bestand am Stichtag, kein Jahresbetrag — und sie zählen die Stadt als Rechtsträger (Kernhaushalt und Eigenbetriebe, ohne die rechtlich selbstständigen Beteiligungen). Der Wortlaut dieser Abgrenzung steht einmal in council.schulden.ABGRENZUNG und wird von dort zitiert, nie nachformuliert.
  • Investitionen stehen im Finanzhaushalt, die Aufwendungen im Ergebnishaushalt: zwei Haushalte, deren Beträge nicht verrechenbar sind.
  • Der Stellenplan bindet „besetzt“ und „nicht besetzt“ an die Vorjahresspalte. Stellen − besetzt mischt zwei Stichtage und steht in keinem Dokument.
  • Die Änderungslisten sagen, wer etwas ändern wollte und ob es durchkam — nicht was genau. Der Inhalt liegt in Anlagen ohne Volltext; ohne diesen Satz füllt das Modell die Lücke mit Plausiblem.

Drei Entscheidungen dahinter, die sich beim Weiterbauen leicht umdrehen lassen — und dann Schaden anrichten:

  1. Nicht am LLM-Fragetyp. Der Typ geld ist ein Modell-Urteil und lautet für „Was hat das Rechnungsprüfungsamt beanstandet?“ mit gutem Grund thema — es ist keine Betragsfrage. Hinge der Haushalts-Kontext am Typ, bekämen genau diese Fragen nichts. geld bleibt trotzdem als Auffangnetz: Sagt das Modell geld und trifft kein Muster, kommen die Plan-Zahlen.
  2. Nicht an den expandierten Suchbegriffen. Der Analyse-Prompt verlangt ausdrücklich eine Umformulierung „aus anderem Blickwinkel — z. B. die Sachstands-Frage zusätzlich als Finanzierungs-Frage“. Wer die Facetten daran festmacht, zieht den halben Haushalt in jede Stadion-Frage. Der Wortlaut entscheidet, ob gefragt wird; die Expansion entscheidet, was die Quelle liefert.
  3. Feuern heißt nicht anhängen. Eine Facette kostet zunächst nur eine Datenbank-Abfrage. „Was kostete der Stadionumbau?“ befragt die Produkt- und die Plan-Ebene, findet dort nichts (ein Stadionumbau ist weder Produkt noch Teilhaushalt) und schreibt kein Zeichen in den Prompt.

Der Prompt-Abschnitt ist auf 4.500 Zeichen gedeckelt (qa.GELD_MAX_CHARS); reißt das Budget, fallen die hinteren Facetten ganz weg statt alle in der Mitte abzubrechen. Echte Fragen kosten gemessen 616–1.755 Zeichen — der Deckel ist für den Normalfall keine Fessel. Er greift erst, wenn eine Frage ein halbes Dutzend Quellen auf einmal zieht; ungedeckelt wären es dann rund 8.300 Zeichen.

Genau daran hängt auch die Reihenfolge in qa.GELD_FACETTEN: Die drei neuen Bestands-Quellen stehen vorn, weil sie nur bei ihren eigenen, eindeutigen Wörtern feuern — wer nach Schulden fragt, darf den Schuldenstand nicht als Erstes verlieren. antraege steht bewusst nicht vorn, obwohl es genauso eng auslöst: Der Baustein ist mit 1.379 Zeichen der dickste, und vorn gestellt fraß er zusammen mit den anderen dreien das ganze Budget — eine Frage, die alles zog, behielt danach von den zehn älteren Quellen keine einzige. Seine eigene Frage zieht sonst nur plan und ansatz, und die stehen beide dahinter; hinter produkte kostet er also nichts.

Jede Zeile trägt ihren Beleg aus council_provenance (Dokument, Fundstelle, Seite, Stichtag) und ihr Jahr — die Quellen enden zu verschiedenen Zeitpunkten, und eine Antwort, die das verschweigt, behauptet eine Aktualität, die die Daten nicht haben. Haushaltszahlen sind keine Beschlüsse und bekommen deshalb nie eine [id].

Gemessen wird das Ganze in tests/test_qa_geldquellen.py: ein Korpus echter Fragen mit erwarteten Quellen, dazu Negativfälle (Fragen, die nichts laden dürfen) und Formulierungs-Varianten derselben Frage. Ein manuell startbarer Lauf am Dateiende prüft zusätzlich die LLM-Klassifikation gegen denselben Korpus.

Aspekt Status
Provider OpenRouter via openai-SDK, mit DSGVO-Provider-Routing (siehe ADR 0002)
Structured Output JSON-Mode (response_format={"type":"json_object"})
Caching Ausschuss-Zusammenfassungen per Agenda-Hash
Prompts zentral in kern/prompts.py, versioniert im Repo
Modelle je Aufgabe per Env-Variable wählbar, Defaults greifen

Das Eval-Harness (Details) misst die Qualität der beiden Matching-Pipelines gegen handgelabelte Fälle:

  • watcher — Tagesordnung → Thema (mengenbasiertes Precision/Recall/F1- Scoring über (Thema, TOP)-Paare).
  • committee — der Routine-Filter der Ausschuss-Zusammenfassungen.

Ein Runner liefert das Scoreboard; gespeicherte Baselines machen Prompt- und Modell-Änderungen vergleichbar, statt „gefühlt“ besser zu sein.