Betrieb
Ratslotse läuft auf einer eigenen App-VM hinter einer Edge-VM. Ausgeliefert wird ausschließlich über GitHub Actions; alles Wiederkehrende (Scraping, LLM-Anreicherung, Backups) läuft als geplanter Job auf der App-VM. Diese Seite beschreibt die Betriebsseite: was wann deployt, was wann läuft, was schiefgehen kann und wie es gemeldet wird.
Konkrete Hosts, Benutzer und Serverpfade stehen bewusst nicht in dieser
Doku — sie liegen als GitHub-Secrets bzw. in der .env auf dem Server.
Branch-Modell
Abschnitt betitelt „Branch-Modell“Seit 08/2026 ist main der Prod-Stand und dev der Integrations-Branch:
- Features gehen per Pull Request nach
dev(Squash-Merge). Jeder Push aufdevdeployt automatisch auf die Dev-VM — dort reift ein Release-Paket. - Fixes gehen weiterhin einzeln per Pull Request nach
main(Squash-Merge) und erreichen Prod sofort. Danach wirdmainnachdevzurückgemergt, damit der nächste Release-PR konfliktfrei bleibt:git checkout dev && git merge origin/main && git push origin dev. - Ein Release ist ein Pull Request
dev→mainmit Merge-Commit (nicht squashen — sonst divergieren die Branches dauerhaft). Der Versionsschnitt im Changelog und der Git-Tag gehören in diesen PR (siehe Changelog). - Umgebungs-Gate: Der Dev-Build setzt
NEXT_PUBLIC_RATSLOTSE_ENV=dev(der Prod-Build nicht). Features, die nur auf der Dev-Umgebung sichtbar sein sollen, prüfen diese Variable und liefern auf ProdnotFound()— der Code kann so gefahrlos mit einem Release nachmainfahren. - Kein Force-Push auf
dev: Der Branch trägt gemeinsame Historie. (Bis 08/2026 wardevein beweglicher Zeiger, auf den man beliebige Stände force-pushen konnte — das gilt nicht mehr.)
Changelog
Abschnitt betitelt „Changelog“Jeder nutzerrelevante Pull Request legt eine Datei unter changelog.d/ an,
statt in CHANGELOG.md zu schreiben:
---kategorie: hinzugefuegt # oder: geaendert | behoben---
**Kernsatz fett.** Danach der Fließtext, deutsch, ohne PR-Nummer.Der Grund ist ein rein mechanischer: Alle PRs schrieben bisher in dieselben
Zeilen unter ## [Unreleased], also kollidierte bei parallelen Zweigen
zuverlässig jeder Merge an derselben Stelle. Eine Datei je PR kollidiert nie.
Dazu kommt, dass die PR-Nummer beim Schreiben des Eintrags noch gar nicht
existiert — geraten wurde sie prompt falsch.
Beim Versionsschnitt (im Release-PR dev → main) sammelt
scripts/changelog_schnitt.py die Fragmente ein:
.venv/bin/python scripts/changelog_schnitt.py 1.13.0 --trocken # anschauen.venv/bin/python scripts/changelog_schnitt.py 1.13.0 # schreibenDas Skript hängt an jedes Fragment die Nummer des Squash-Commits, der die Datei
angelegt hat (git log --diff-filter=A; findet es keine, warnt es und lässt den
Eintrag ohne Nummer), sortiert es unter ## [x.y.z] – Datum in seinen
Abschnitt, löscht die Fragmente und zieht die Compare-Links am Dateiende nach.
Nach dem Merge des Release-PRs wird der annotierte Tag gesetzt — und aus ihm das GitHub-Release:
git tag -a v1.13.0 -m "Ratslotse 1.13.0" && git push origin v1.13.0.venv/bin/python scripts/changelog_schnitt.py 1.13.0 --release \ --titel "v1.13.0 — Belege bis zur Protokollseite"Der zweite Schritt war bis 09/2026 Handarbeit und fiel dreimal aus: v1.14.0,
v1.15.0 und v2.0.0 lagen als Tags im Repository, ohne dass jemand ein Release
daraus machte — GitHub führt einen bloßen Tag nur unter „Tags“, die
Release-Seite zeigte deshalb wochenlang v1.13.2 als neuesten Stand. --release
liest den fertigen Abschnitt aus CHANGELOG.md, prüft, dass der Tag wirklich
bei origin liegt und das Release noch nicht existiert, und legt es über gh
an. Zwei Feinheiten: Ist ein Jahrgang zu umfangreich für einen Release-Text —
GitHub nimmt 125.000 Zeichen, der Abschnitt zu 1.14.0 hat 181.451 — gehen statt
eines Fehlers nur die Kernsätze aller Einträge raus, darüber der Verweis auf den
vollständigen Text. Und --latest setzt das Skript nur, wenn die Version im
Changelog obenauf steht, damit ein nachgereichtes Release für einen alten Tag
den aktuellen Stand nicht verdrängt.
Von Hand direkt unter ## [Unreleased] eingetragene Einträge bleiben gültig —
sie wandern beim Schnitt unverändert mit unter die neue Version. /changelog
rendert zur Build-Zeit CHANGELOG.md und die noch offenen Fragmente; für
Leser*innen sind beide Wege nicht unterscheidbar. Ob alle Fragmente lesbar
sind, prüfen tests/test_changelog_fragmente.py und
scripts/changelog_schnitt.py --pruefen.
Deploy-Wege
Abschnitt betitelt „Deploy-Wege“Sechs Workflows in .github/workflows/:
| Workflow | Trigger | Was passiert |
|---|---|---|
test.yml |
Push auf main, jeder Pull Request |
Python 3.12, requirements.txt + requirements-dev.txt, dann pytest tests/ -q. |
deploy.yml |
pull_request: types:[closed] auf main mit merged == true, zusätzlich workflow_dispatch |
Test-Gate (derselbe Lauf wie test.yml, als harte needs-Abhängigkeit), dann Doku-Build, rsync des Codes auf die App-VM (SSH mit ProxyJump über die Edge-VM) und Neustart der beiden systemd-Services. |
deploy-dev.yml |
jeder Push auf dev, zusätzlich workflow_dispatch |
Deployt auf die Dev-VM — ohne Test-Gate (siehe unten). |
deploy-feature.yml |
jeder Push auf feature, zusätzlich workflow_dispatch |
Deployt auf die zweite Instanz auf derselben Dev-VM (eigenes Verzeichnis, eigene Ports, eigene Datenbanken) — ebenfalls ohne Test-Gate. |
docs.yml |
PR und Push auf main, nur bei Änderungen unter docs-site/** |
Baut die Starlight-Doku und schlägt fehl, wenn sie nicht mehr baut (kaputte Links, Frontmatter, MDX). |
docs-review.yml |
PR opened / reopened / ready_for_review, keine Forks |
KI-Review, das den Diff auf Doku-Drift prüft und genau einen PR-Kommentar postet. Nur contents: read — die Action kann nichts committen. continue-on-error: true, der Review ist also kein Qualitäts-Gate. |
Der Prod-Deploy im Detail (deploy.yml, in dieser Reihenfolge):
- Testjob — schlägt er fehl, läuft der Deployjob gar nicht erst an.
docs-sitemit Node 22 bauen (npm ci && npm run build).rsync -az --deletedes Repos auf die App-VM. Ausgespart bleiben.env,data/,.venv/,.git/,web/frontend/node_modules/,web/frontend/.next/,web/frontend/public/docs/,docs-site/node_modules/unddocs-site/dist/— Datenbanken und Secrets überlebt jeder Deploy also unverändert.- Zweiter rsync: das gebaute
docs-site/dist/in daspublic/docs/-Verzeichnis des Frontends. Next.js liefert die Doku damit unter/docsaus; die Edge braucht dafür keine eigene Konfiguration. - Per SSH auf der App-VM: Backend-Abhängigkeiten nachinstallieren (idempotent),
npm ci+next buildim Frontend, dann Neustart der Services für Backend-API und Frontend.
Warum der Umweg über den gemergten PR statt „Push auf main deployt“: siehe
ADR 0008. Kurz — ein direkter Push auf
main läuft nur durch die Tests und erreicht die Produktion nicht.
Wenn der Merge keinen Deploy auslöst: Der Workflow lässt sich in der
Actions-UI von Hand starten („Deploy to VPS“ → Run workflow, Ref main) — mit
demselben Test-Gate davor. Der Notausgang stammt vom 26.08.2026: Während einer
GitHub-Actions-Störung fiel das closed-Ereignis zweier gemergter PRs ersatzlos
aus, main trug den neuen Stand und Prod nicht. Einen Lauf, den es nie gegeben
hat, kann man nicht neu starten; erkennbar ist der Fall daran, dass zum
Merge-Commit gar kein Workflow-Lauf existiert
(gh api repos/<owner>/<repo>/actions/runs?head_sha=<sha> bleibt leer).
Verwendete GitHub-Secrets: SSH_PRIVATE_KEY (Deploy-Key), VPS_HOST,
VPS_DEV_HOST, VPS_PROXY_HOST, VPS_USER, VPS_SSH_PORT sowie
ANTHROPIC_API_KEY für docs-review.yml. Werte stehen ausschließlich in den
Repository-Secrets.
Dev-Umgebung
Abschnitt betitelt „Dev-Umgebung“Neben der Produktion läuft eine eigene Dev-VM mit eigenen Datenbanken und eigenen Secrets. Sie unterscheidet sich bewusst von Prod:
- Basic-Auth vor dem vhost — die Umgebung ist nicht öffentlich erreichbar.
- Kein Mailversand — es ist kein Resend-Key hinterlegt, E-Mails werden im Code still übersprungen.
- Keine Crons — geplante Jobs laufen dort nicht mit; Dev ist zum Anschauen da, nicht zum Datensammeln.
- Eigene Datenbanken und ein eigener OpenRouter-Key, damit Testläufe weder Prod-Daten noch das Prod-Kostenbudget berühren.
Deployt wird auf jeden Push auf den Branch dev — im Regelfall also bei
jedem gemergten Feature-PR und bei jedem Rückmerge von main (siehe
Branch-Modell). Die VM holt den Stand per git fetch +
git reset --hard <sha> statt per Merge — robust gegen jede Art von
Branch-Umbau. .env, data/, .venv/ und node_modules/ sind untracked
und bleiben unberührt.
Der Frontend-Build läuft mit NEXT_PUBLIC_RATSLOTSE_ENV=dev — das ist das
Umgebungs-Gate, mit dem einzelne Seiten nur auf der Dev-Umgebung sichtbar
sind (der Prod-Build setzt die Variable nicht, dort liefern solche Seiten
notFound()). Da NEXT_PUBLIC_-Variablen zur Build-Zeit einkompiliert
werden, braucht die Dev-VM dafür keinen .env-Eintrag.
Der Lauf hat kein Test-Gate (die Tests laufen ohnehin an jedem PR), ein
Kommando-Timeout von 30 Minuten für npm ci + next build und am Ende zwei
Smoke-Checks gegen Frontend und /api/health plus die Rauchprobe. Prod bleibt
davon vollständig unberührt.
Der concurrency-Block teilt sich die Gruppe mit dem Feature-Deploy (siehe
Feature-Umgebung) und steht auf cancel-in-progress: false. Beide Instanzen liegen auf derselben VM; zwei gleichzeitige
next build passen dort nicht nebeneinander, und der OOM-Killer trifft dann
den laufenden Dienst der anderen Instanz. Sie bauen deshalb nacheinander,
und keiner der beiden Läufe wird abgebrochen — ein abgebrochener Deploy
hinterließe eine Umgebung auf altem Stand, ohne dass es jemandem auffällt.
Feature-Umgebung
Abschnitt betitelt „Feature-Umgebung“Auf derselben VM wie die Dev-Umgebung läuft eine zweite, vollständig
getrennte Instanz für den Branch feature. Sie hat ein eigenes
Arbeitsverzeichnis, eigene systemd-Units, eigene Ports, eine eigene .env
und eigene Datenbanken — dev und feature teilen sich nur die Maschine.
Wozu: dev trägt den Stand, der als nächstes nach main fährt. Wer etwas
Größeres oder Wackeliges vorzeigen will, ohne diesen Stand anzufassen, mergt
es nach feature und zeigt es unter der eigenen Subdomain.
- Eigene Datenbanken sind der Kern der Trennung. Ein Feature-Branch
bringt typischerweise Migrationen mit. Teilte er die Dateien mit dev, zöge
die erste Migration die Dev-Umgebung mit um — und zurück käme man nur über
ein Backup. Der Bestand wurde einmalig als konsistenter
sqlite3-Backup-Schnappschuss aus der Dev-Datenbank kopiert. - Gleiches Umgebungs-Gate wie dev (
NEXT_PUBLIC_RATSLOTSE_ENV=dev) — sonst wäre die Instanz für genau die Features blind, für die man sie baut. - Basic-Auth vor dem vhost, kein Mailversand, keine Crons.
- Kein Force-Push nötig, aber erlaubt:
featureist ein Wegwerf-Zweig. Der Deploy holt den Stand pergit fetch+git reset --hard <sha>und kommt mit jedem Umbau der Historie zurecht.
Frisch halten heißt: dev nach feature mergen (nicht umgekehrt). Fertige
Arbeit geht wie immer per Pull Request nach dev.
Geplante Jobs (Cron)
Abschnitt betitelt „Geplante Jobs (Cron)“Alle Jobs laufen auf der App-VM; maßgeblich ist die dort eingetragene Crontab.
Die Zeitpläne stehen als Docstring im jeweiligen Skript und in
scripts/README.md.
| Skript | Rhythmus | Aufgabe |
|---|---|---|
backup_db.py |
täglich 0 3 * * * |
SQLite-Backup beider Datenbanken, rotierend, optional off-site gespiegelt. |
check_committees.py |
täglich 0 7 * * * |
Gremienliste und Kalender (3 Monate voraus) auffrischen, Terminplan-Sitzungen ohne Tagesordnung mitschreiben, Tagesordnungen zusammenfassen und Ausschuss-Abonnent*innen benachrichtigen (auch bei geänderter Tagesordnung, erkannt über einen Agenda-Hash). |
check_council.py |
zweimal täglich 0 8,14 * * * |
Kommende Sitzungen gegen die Themen aller Nutzer*innen klassifizieren und Treffer per E-Mail/Push ausliefern. |
check_protocols.py |
täglich 0 9 * * * |
Neue Protokolle parsen — und alles Nachgelagerte gleich mit (siehe unten). |
weekly_enrich.py |
sonntags 0 3 * * 0 |
LLM- und Embedding-Backfills in 14 Schritten (siehe unten). |
remind_setup.py |
täglich 0 11 * * * |
Genau eine Service-Mail an Konten, die den Einrichtungs-Assistenten angefangen und seit 48 h nicht beendet haben. |
abendmeldungen.py |
täglich 0 18 * * * |
Abend-Anlässe aus Design 30a: N5 Vorabend-Erinnerung täglich, N6 Wochenüberblick nur sonntags. N5 ist standardmäßig aus, N6 seit 09/2026 für neue Konten an. |
check_finanzdaten.py ¹ |
sonntags 0 6 * * 0 |
Neue Haushalts-Jahrgänge aus dem Anlagenbestand einlesen (Jahresabschluss, Teilhaushalts-Pläne, Prüfberichte) und melden, wenn ein erwarteter Jahrgang ausbleibt. Lädt nichts herunter, ergänzt nur Fehlendes — siehe Stadtfinanzen. |
check_beteiligungsbericht.py |
sonntags 30 6 * * 0 |
Lädt die Beteiligungsberichte von oldenburg.de und liest Gesellschaften, Aufsichtsorgane und Kennzahlen daraus. Der einzige Haushalts-Cron, der selbst herunterlädt. |
archive_statistik.py |
täglich 0 4 * * * |
Sichert die amtlichen Statistik-Quellen versioniert unter data/archiv/ — siehe unten. |
¹ Nur auf Prod — und das ist seit 09/2026 die richtige Seite. Der
Haushalts-Bereich hing bis dahin an einem Umgebungs-Gate und war auf
ratslotse.de unsichtbar; hier stand deshalb das Gegenteil („nur auf der
Dev-VM“). Seit dem Rollen-Umbau hängt er am Recht budget und ist dort für
Ratsmitglieder sichtbar (kern/roles.py), die Daten sind eingelesen. Auf der
Dev-VM gibt es beide Jobs nicht; dort zieht ops-finanzdaten-ingest.yml
den Bestand von Hand nach.
Am 03.09.2026 auf dem Server nachgesehen, nachdem hier und in kern/jobs.py
jahrelang eine Angabe stand, die nie gestimmt hat: Beide Takte sind
wöchentlich, nicht 14-tägig. Was in dieser Tabelle steht, ist eine Kopie
der crontab — und eine Kopie wird nur dann nicht zur Lüge, wenn jemand
nachsieht statt abzuschreiben.
Was der Protokoll-Lauf inline nachzieht
Abschnitt betitelt „Was der Protokoll-Lauf inline nachzieht“check_protocols.py ist längst mehr als ein Protokoll-Parser: Es ruft die
Sub-Steps direkt als Python-Funktionen auf, jeweils mit einer Obergrenze, damit
der Tageszuwachs abgedeckt ist, ohne dass ein einzelner Lauf entgleist. An
Tagen ohne neue Protokolle sind die LLM-Schritte ein No-op.
| Schritt | Modul | Limit je Lauf |
|---|---|---|
| Protokolle der letzten 90 Tage neu prüfen und parsen | backfill_protocols.process_range |
Zeitfenster LOOKBACK_DAYS = 90 |
| Themenfeld-Klassifikation aller noch unklassifizierten Beschlüsse | classify_decisions.process |
kein Limit (idempotent) |
| Beschlüsse gegen die Stadtziele bewerten, inkrementell | track_goals.process |
nur noch nicht verknüpfte Paare |
| €-Beträge extrahieren (Regex, kein LLM) | extract_amounts.process |
nur fehlende |
| „Einfach erklärt“-Kurzfassungen | generate_simple_summaries.process |
60 |
| Gesprächswert (Interessantheit) | rate_interest.process |
200, 2 Worker |
| Tragweite | rate_impact.process |
200, 2 Worker |
| Vorlagen-Volltexte nachladen (Netz + pypdf, kein LLM) | backfill_vorlagen.process_missing |
300 |
| Anlagen/Anträge nachladen + jüngste Tagesordnungen erneut scannen | backfill_anlagen |
300 (+ Rescan) |
| Beratungsfolge nachziehen + bewegliche Stationen aktualisieren | backfill_beratungen |
300 (+ Rescan) |
| Wichtigkeits-Score neu berechnen (Heuristik, kein LLM) | CouncilStore.backfill_importance |
alle |
| Volltext-Index neu bauen | CouncilStore.rebuild_fts |
alle |
Die beiden LLM-Scores und die Vorlagen-Abrufe sortieren „neueste zuerst“ — das kleine Limit trifft also zuverlässig den Tageszuwachs. Den historischen Bestand arbeiten die Wochentranchen ab. Der Wichtigkeits-Score wird bewusst nach der frischen Tragweite gerechnet, damit die 50/50-Mischung sofort greift (siehe Bewertungs-Scores).
Schrittfolge des Wochenlaufs
Abschnitt betitelt „Schrittfolge des Wochenlaufs“weekly_enrich.py startet seine Schritte als Subprozesse. Maßgeblich ist die
STEPS-Liste im Skript — sie hat inzwischen 14 Einträge, in dieser Reihenfolge:
- Entitäten (NER) —
extract_entities.py, bautcouncil_entitiesneu auf. - Beschreibungen —
describe_entities.py, füllt fehlende Entitäts-Texte (slug-basiert, überlebt den Rebuild). - Geocoding —
geocode_entities.py, verortet neue Orts-Entitäten über Nominatim. - Embeddings / Ähnliche —
embed_decisions.py, berechnet „Ähnliche Beschlüsse“ neu (ADR 0003). - Verwandte Themen —
build_entity_relations.py, berechnet „Hängt zusammen mit …“ je Thema (kein LLM). Braucht Schritt 1 und 4. - Themen ↔ Beschlüsse —
match_topics_decisions.py, matcht Nutzer-Themen semantisch gegen Beschlüsse. - Themenfeld-Rückblicke —
generate_field_recaps.py, erneuert nur veraltete Felder (faktisch ≈ monatlich je Feld). - Einfach erklärt —
generate_simple_summaries.py, 500er-Tranche, neueste zuerst. - Personen-Stammdaten —
backfill_stammdaten.py, Mandatsträger und Mitgliedschaften aus dem Ratsinfo (kein LLM). - Tragweite —
rate_impact.py --limit 500, bewusst vor dem Wichtigkeits-Score. - Wichtigkeits-Score —
score_importance.py, Heuristik über den Gesamtbestand. - Quizfragen —
generate_quiz.py, füllt nur Gebiete unter der Ziel-Fragenzahl auf. - Interessantheit —
rate_interest.py --limit 500, neueste zuerst. - Fundstücke —
generate_fundstuecke.py --days 21, legt fehlende Kalendertage 21 Tage im Voraus an.
Jeder Schritt läuft fehlertolerant: Ein Fehlschlag wird protokolliert und gemerkt, stoppt aber die übrigen Schritte nicht. Am Ende gibt der Lauf eine Bilanz („n/14 ok“) aus und setzt einen Exit-Code ungleich null, sobald mindestens ein Schritt gescheitert ist — daraus wird für den Alarmweg eine Exception erzeugt.
Fehler-Alarme
Abschnitt betitelt „Fehler-Alarme“Alle Cron-Einstiegspunkte laufen in run_guarded aus kern/alerts.py. Stürzt
ein Job ab, passiert dreierlei: der Traceback landet im Log (journald bzw.
Cron-Log), eine Alarm-Mail geht an ALERT_EMAIL (Fallback WEB_ADMIN_EMAIL)
und die Exception wird erneut geworfen, damit Cron einen Exit-Code ungleich
null sieht. Der Mailweg ist strikt best-effort: ohne RESEND_API_KEY — oder
wenn der Versand selbst scheitert — bleibt der Alarm im Log, und der
Alarmpfad selbst wirft nie.
Ops-Workflows (manuell auslösbar)
Abschnitt betitelt „Ops-Workflows (manuell auslösbar)“Sechs ops-*-Workflows, alle per workflow_dispatch. Fünf laufen ohne Inputs mit
festen Befehlen; ops-entity-dubletten.yml hat zwei Schalter (siehe Tabelle).
Sie nutzen denselben Deploy-Key und ProxyJump wie deploy.yml und führen die
Skripte direkt auf der App-VM aus — praktisch, wenn kein SSH-Zugang zur Hand
ist. Nur Collaborator können sie starten.
| Workflow | Wofür | Idempotent? |
|---|---|---|
ops-vorlagen-backfill.yml |
Holt alle fehlenden Vorlagen-Volltexte und Anlagen (inkl. Antragsteller-Erkennung) nach und baut anschließend den FTS-Index neu. Timeout 120 min. | Ja — nur Fehlendes; der Rebuild ist reproduzierbar. |
ops-stammdaten-backfill.yml |
Zieht Beratungsfolgen aller eingelesenen Vorlagen sowie Personen- und Gremien-Stammdaten aller Wahlperioden nach. Reines Netz-Parsing, kein LLM. Timeout 120 min. | Ja — die Mitarbeit wird je Person komplett ersetzt. |
ops-recaps-regenerieren.yml |
Erzeugt alle Themenfeld-Rückblicke neu (--force), sinnvoll nach einer Änderung am Recap-Prompt in kern/prompts.py statt bis Sonntag zu warten. Kostet ein paar Cent LLM. |
Nein im engeren Sinn — --force überschreibt bewusst alle Rückblicke. |
ops-quiz-backfill.yml |
Generiert Quizfragen für alle Gebiete (Stadtteile + große Themen) bis zur Ziel-Fragenzahl (--target 10), inkl. Verify-Pass. Timeout 60 min. |
Ja — nur Gebiete unter Ziel werden aufgefüllt. |
ops-tragweite-rollout.yml |
Schaltet den Tragweite-Score erstmals scharf: Voll-Backfill über alle Beschlüsse ohne impact, danach Neuberechnung des Wichtigkeits-Scores. |
Ja — bewertet nur Beschlüsse ohne Score. |
ops-entity-dubletten.yml |
Sucht doppelte Themen (dieselbe Sache unter mehreren Namen) und führt die vom LLM bestätigten zusammen. Zwei Inputs: nur_bericht (Default true → zeigt nur an, schreibt nichts) und trocken (mit LLM-Prüfung, ohne zu speichern). Timeout 40 min. |
Ja — jede Zusammenführung ist im Admin-Panel einzeln wieder auflösbar. |
Das Golden-Set-Gate im Tragweite-Rollout: Schritt 1 ist
scripts/eval_impact.py --rate-missing. Bestanden ist der Lauf nur bei
Spearman-Rangkorrelation ≥ 0,7 und Band-Trefferquote ≥ 70 %. Fällt das Gate
durch, endet es mit Exit 1 und der Workflow bricht ab — der Voll-Backfill
startet dann gar nicht erst, stattdessen ist der Prompt nachzuschärfen. Erst
nach bestandenem Gate startet Schritt 2 den Voll-Lauf; der läuft serverseitig
per nohup weiter, während der Workflow selbst schon fertig ist.
Städte-Speicher: der einmalige Backfill
Abschnitt betitelt „Städte-Speicher: der einmalige Backfill“data/cities.sqlite (Ratsdokumente der Vergleichsstädte, council/cities)
entsteht nicht allein aus dem Wochen-Cron. check_cities.py schaut je
Stadt 60 Tage zurück — das hält den Bestand aktuell, baut ihn aber nie auf.
Der Aufbau ist ein einmaliger Lauf je Stadt:
# Oldenburg: kein Netz, Minuten — der Adapter liest council.sqlitepython scripts/cities_backfill.py --run --body oldenburg# Die fünf mit OParl: je Stadt Stunden, ein Abruf je Sekunde je Hostfor stadt in osnabrueck braunschweig muenster potsdam magdeburg; do python scripts/cities_backfill.py --run --body "$stadt" --since 2023-01-01doneDanach einordnen und indizieren (beides über check_cities.py oder von Hand
über council.cities.pipeline). Die Einordnung kostet rund 0,31 $ je 1.000
Vorlagen; CITIES_ANNOTATE_MAX deckelt sie je Lauf, damit ein Rückstand über
mehrere Wochen abgebaut wird statt an einem Sonntag teuer zu werden.
Reihenfolge beachten, und sie ist seit 09/2026 länger:
python scripts/cities_backfill.py --run --stage annotate # classify + effortpython scripts/cities_backfill.py --run --stage index # Chunks, Vektoren, FTS, Nachbarnpython scripts/cities_backfill.py --run --stage cluster # Ideen-Cluster über Städte# fit läuft nur im Cron — es braucht Index UND ClusterJede Stufe braucht die vorige: Die Einordnung sagt, was eine Idee ist; der
Index rechnet Nachbarschaften über alle Städte; die Cluster fassen zusammen,
was dieselbe Idee ist; und fit urteilt erst, wenn es beides hat.
Die Niederschriften laufen daneben her. Seit 09/2026 holt fetch auch die
Sitzungsprotokolle (role='protocol', nur Sitzungen der letzten 24 Monate),
pipeline.split_protocols schneidet sie in ihre Tagesordnungspunkte, und
council.cities.reasons.run liest daraus, warum ein Rat so entschieden hat —
gedeckelt über CITIES_REASON_MAX (Vorgabe 400 Abschnitte je Lauf, unter
0,10 $). Gefragt werden nur Punkte, deren Idee mindestens eine andere Stadt
teilt; nur dort zeigt die Karte das „Warum“ überhaupt.
Eine Stadt, die Protokoll-Adressen nennt, aber keine ausliefert, wird nach drei Fehlversuchen übersprungen — Magdeburg nennt 606 Adressen, von denen jede einzelne mit 404 antwortet.
Nach jeder Ernte die Plausibilität prüfen. Am 08.09.2026 lagen vier Ernte-Fehler gleichzeitig im Bestand, und kein einziger hat sich gemeldet — kein Absturz, kein roter Test, keine auffällige Zahl:
python scripts/cities_backfill.py --pruefenVier Bänder je Stadt (Vorlagen je Sitzung, Anteil mit Ergebnis, mit Text, mit
Beratung), aus dem Bestand gemessen. Dazu die Ergebnistexte, die die Zuordnung
nicht kennt — das ist die Liste, mit der man eine neue Stadt anschließt. Die
Einzelheiten stehen in council/cities/CLAUDE.md, die Prüfung läuft auch im
Wochen-Cron und schreibt implausibel in die Kennzahlen.
Was der Bestand hergibt, sobald er steht:
python scripts/cities_cluster_bericht.py --ohne-oldenburg # Ideen, die Oldenburg fehlenpython scripts/cities_gegenrichtung.py --offen # wie ging es anderswo aus?Platz: Der Speicher wächst mit der Historie; drei Jahre über fünf Städte
plus Oldenburg seit 2018 sind rund 600 MB. check_herzschlag.py schaut auf
den freien Platz, aber vor dem ersten Lauf lohnt ein Blick.
Nie zwei Läufe auf dieselbe Datei. --parallel erntet mehrere Städte
gleichzeitig in getrennte Rohdateien; das ist erlaubt. Zwei Prozesse, die
beide in cities.sqlite schreiben, sind es nicht — SQLite quittiert das mit
database is locked, und im Probelauf starb der unterlegene Thread still.
Ein anderes Einbettungs-Modell probieren. Der Speicher hält Vektoren und Nachbarschaften je Modell getrennt, ein neues liegt also neben dem alten und löscht nichts:
python scripts/cities_modellvergleich.py --einbetten --modell <name>python scripts/cities_modellvergleich.py --modell <name>Gemessen am 08.09.2026 über 16.585 Papiere: Das größere
paraphrase-multilingual-mpnet-base-v2 (768 Dimensionen) fand weniger von
dem, was ein Mensch als richtigen Beleg bezeichnet hat (12 von 23 gegen 19 von
23), und drängte fast alle Kanten ins obere Band — 20.523 über 0,85, wo das
heutige Modell 233 hat. Die Schwellen der Anzeige sind auf dieses Band geeicht;
wer das Modell wechselt, misst sie neu.
Kennzahlen des Wochenlaufs: papers_total und papers_oldenburg stehen
im Admin-Panel unter Statistik → Cron-Jobs. Oldenburg eigens, weil es die
Stadt ist, gegen die alles verglichen wird: Fällt die Zahl, fehlt dem
Vergleich die eine Seite — und zwar still, weil die anderen Städte
weiterlaufen.
Der Weg auf Prod
Abschnitt betitelt „Der Weg auf Prod“Beide Schalter des Städtevergleichs sind Schulden mit Fälligkeitsdatum:
Jeder nennt in kern/features.py unter fertig_wenn, woran man erkennt, dass
er weg kann. Sie umzulegen ist keine technische Frage, sondern eine
inhaltliche — deshalb steht hier die Reihenfolge, nicht der Griff.
| # | Schritt | Bedingung |
|---|---|---|
| 1 | cities.sqlite auf der Dev-VM aufbauen (Backfill oben, Stunden) |
keine — ohne sie zeigt dev nichts |
| 2 | andere-staedte in FEATURE_FLAGS auf Prod |
Der Block lag vier Wochen auf dev, und zwei Mandatsträger*innen haben die Treffer als brauchbar bestätigt |
| 3 | cities.sqlite auf der Prod-VM aufbauen und den Wochen-Cron eintragen |
vor Schritt 2, sonst zeigt der Block dort nichts |
| 4 | ideen-anderswo in FEATURE_FLAGS auf Prod |
Tim hat zwei Themenfelder durchgesehen und die Urteile für tragfähig erklärt |
| 5 | Beide Schalter aus der Registry nehmen | wenn sie auf Prod stehen und niemand sie mehr umlegt |
Ein Schritt liegt quer dazu: die Fassung des Urteils. Die Oberfläche zeigt
fit/1; Fassung 2 (Ideen-Cluster, Aufwandsklasse, drei Stimmen) liegt
daneben und ist noch nicht umgestellt. Der Grund steht als gut_wenn am
Annotator und ist eine Zahl, keine Meinung:
Fassung 2 ist reif, wenn der Bestand einmal damit gerechnet ist und der Anteil „fehlt + lohnt sich“ unter 30 % liegt. Fassung 1 liegt bei 50 % (649 von 1.290 Urteilen) — eine Liste, auf der jede zweite Zeile ein Volltreffer ist, ist kein Vorschlag, sondern ein Katalog.
Umgestellt wird an genau einer Stelle: CitiesStore.IDEEN_FIT. Danach der
Aufräumlauf, der Fassung 1 löscht — aber erst dann.
Auf dev steht FEATURE_FLAGS=*, dort sind beide also schon an — sichtbar
wird trotzdem erst etwas, wenn Schritt 1 gelaufen ist.
Schritt 5 ist kein Aufräumen, sondern Teil der Sache. Ein Schalter, den
niemand mehr umlegt, ist eine Verzweigung, die jeder mitliest und niemand
braucht; tests/test_features.py meldet einen, den keine Oberfläche mehr
abfragt.
Was der Rollout NICHT braucht: einen App-Store-Build. Beide Oberflächen
holen ihre Schalter über /api/app-config; die ausgelieferte App bekommt die
Seite in dem Moment, in dem der Schalter auf Prod steht.
Lokal zum Arbeiten: python scripts/lokale_daten.py hol --mit-staedten
und setz --mit-staedten nehmen den Speicher vom Server mit. Ohne den
Schalter bleibt alles wie bisher; 600 MB will nicht jede*r auf dem Notebook.
Eine Abspeckung wie bei der Rats-Datenbank braucht es nicht — es stehen
ausschließlich öffentliche Ratsdokumente anderer Städte darin, keine Konten,
keine Personendaten.
Backups
Abschnitt betitelt „Backups“scripts/backup_db.py läuft täglich um 03:00 und sichert beide Datenbanken
(ratslotse.sqlite und council.sqlite) mit der sqlite3-Backup-API — also
konsistent, ohne den laufenden Betrieb zu stoppen. Die Kopien landen unter
data/backups/ mit Datum im Dateinamen.
- Rotation: zwei Stufen,
TAEGLICH = 7undWOECHENTLICH = 4. Es bleiben die sieben jüngsten Sicherungen, dazu aus jeder der vier Kalenderwochen vor diesem Fenster die jüngste — zusammen 29 bis 35 Tage Abdeckung. Sieben Tage feinkörnig decken „gestern war es noch gut“ ab, die Wochenmarken alles, was erst später auffällt. Der Zusatz „vor dem Fenster“ ist nicht kosmetisch: Reicht das Tagesfenster in die Vorwoche hinein, läge deren Marke einen Tag neben einem Tagesstand und gewänne keinen Abstand — der Bestand endete dann schon nach 22 Tagen. - Handkopien bleiben liegen. Gezählt wird nur, was
<stamm>_JJJJ-MM-TT.sqliteheißt. Eine von Hand gezogenecouncil_vor_release_v2.0.0.sqlitefällt aus der Rotation heraus: Sie wird nie gelöscht und kostet auch keinen Platz im Bestand. Vorher war beides falsch herum — gelöscht wurdesorted(...)[:-7], und weilcouncil_pre_…alphabetisch hintercouncil_2026-…steht, warfen zwei Handkopien vom August zwei Tagesstände hinaus (am 03.09.2026 lagen deshalb nur fünf Tagesstände voncouncilvor, aber sieben vonnwz). - Off-Site-Mirror (optional): Ist
BACKUP_RSYNC_TARGETgesetzt, wird das Backup-Verzeichnis anschließend perrsync -az --deletegespiegelt; das Ziel ist damit ein exaktes Abbild der lokalen Rotation. Eine Kopie gegen Serververlust, aber kein Archiv: Was lokal gelöscht wird, ist beim nächsten Lauf auch dort weg. Der SSH-Port kommt ausBACKUP_RSYNC_SSH_PORT(Default22),BatchMode=yesverhindert Passwort-Prompts im Cron. Fehler werfen und landen damit im Alarmweg. - Fehlt jede Datenbank, wirft der Lauf bewusst eine Exception — ein stillschweigend leeres Backup gibt es nicht.
Die Datenbankdateien selbst werden vom Deploy nicht angefasst: data/ steht
in der --exclude-Liste des rsync, genau wie .env und .venv/. Ein Deploy
kann den Datenbestand also nicht überschreiben.
Was noch mitgesichert wird: Nicht alles unter data/ steht in einer
Datenbank. dateien_spiegeln() legt zwei Ordner per rsync in
data/backups/ und damit in den Off-Site-Spiegel:
| Ordner | Inhalt | Wiederherstellbar? |
|---|---|---|
data/plaene/ |
gerenderte Planzeichnungen | ja, aber nur über einen Stapellauf über 600 Anlagen |
data/archiv/ |
Statistik-Archiv (s. u.) | nein — die Quellen sind überschrieben |
Statistik-Archiv (archive_statistik.py)
Abschnitt betitelt „Statistik-Archiv (archive_statistik.py)“Das Problem: Die Stadt führt kein Jahrbuch-Archiv. Auf der Übersichtsseite
steht immer nur die jeweils neueste Ausgabe jeder Tabelle, der Dateiname
trägt den Jahrgang (1103-2025-AZ.pdf), und sobald die nächste Ausgabe
erscheint, ist die alte Adresse ein 404 — nachgemessen am 17.08.2026 an
1102-2024, 1103-2024, 1108-2023, 1108-2024 und STJB2024_DS: alle
weg. Das Internet Archive hat vom Statistik-Verzeichnis der Stadt null
Schnappschüsse. Für Tabellen mit nur drei Jahrgängen (1103 Steuern und
Finanzzuweisungen, 0803 Sozialhilfe) ist damit jedes Jahr ein Jahrgang
endgültig verloren. Beim Open-Data-Portal ist es dasselbe, nur leiser: Die
Adressen sind stabil, der Inhalt wird überschrieben.
Was der Job tut: Er sichert, er parst nichts. Drei Quellen:
| Bereich | Woher | Umfang (17.08.2026) |
|---|---|---|
opendata |
opendata.oldenburg.de/data.json und alle darin verlinkten Dateien |
186 Dateien, 10 MB |
jahrbuch |
die Übersichtsseite des Statistischen Jahrbuchs, alle Tabellen-PDFs daraus | 246 PDFs, 56 MB |
kfa |
die Übersichtsseite des Kommunalen Finanzausgleichs beim LSN | 14 Mappen, 3 MB |
Keine festen Adresslisten. Eine feste Liste zeigte nach dem nächsten Erscheinen auf 404-Adressen und fände die neue Ausgabe nicht — sie versagte genau in dem Moment, für den es den Job gibt. Stabil sind die Übersichtsseiten, nicht die Dateinamen.
Wie versioniert wird:
data/archiv/jahrbuch/1103-2025-AZ.pdf/2026-08-17_9f3c1a2b4d5e.pdf └ Ordner heißt wie die Datei ┘ └ Tag ┘└ Hash ┘Datum und Hash: Der Hash allein sagte nicht, wann eine Fassung zuerst
auftauchte; das Datum allein legte dieselben Bytes erneut ab, sobald ein
Server seinen ETag ohne Inhaltsänderung neu vergibt. Entschieden wird über
den Inhalts-Hash — liegt er im Ordner, passiert nichts. Daran hängt die
Idempotenz: Ein zweiter Lauf am selben oder an einem späteren Tag legt nichts
doppelt ab.
data/archiv/manifest.json hält je Adresse ETag, Last-Modified, Hash, Pfad,
Erst- und Letztsichtung sowie den letzten Fehler. Es liegt bewusst im
Archiv statt in der Datenbank: Ein Archiv, dessen Inhaltsverzeichnis
woanders liegt, ist nach einer Wiederherstellung ein Haufen Hashes.
Warum täglich, wenn sich selten etwas ändert: Weil die Änderungen in Schüben kommen (29 Open-Data-Datensätze am 19.06.2026, 20 am 14.07.2026) und Vorlauf bei einer Quelle ohne Archiv der einzige Puffer ist. Der Preis ist klein — gemessen:
| Lauf | geladen | Dauer |
|---|---|---|
| erster | 72,4 MB (447 Dateien) | 2:49 min |
| jeder weitere ohne Änderung | 0,0 MB | 34 s |
Möglich macht das ein dreistufiges Sieb: das modified-Feld des
Open-Data-Katalogs (kein Abruf), danach If-None-Match/If-Modified-Since
(304, keine Bytes), zuletzt der Hash. Die LSN-Adressen schicken weder ETag
noch Last-Modified; dort greift stattdessen, dass eine Download-Nummer
unveränderlich ist — eine neue Ausgabe bekommt eine neue Nummer, auch eine
Korrektur (KFA 2023 steht als „endgültig Korrektur“ neben dem Original).
--ohne-vorpruefung schaltet beide Abkürzungen ab, falls sich eine der
Annahmen als falsch erweist.
Ein 404 beendet den Lauf nicht. Er wird gezählt, ins Manifest geschrieben
(mit Datum) und einmalig an ALERT_EMAIL gemeldet — nicht jeden Tag erneut.
Ein 404 auf eine Jahrbuch-Adresse ist am Erscheinungstag der neuen Ausgabe der
Normalfall; auf eine Open-Data-Adresse ist er es nicht.
LLM-Kosten
Abschnitt betitelt „LLM-Kosten“Jeder LLM-Aufruf kann seinen Token-Verbrauch protokollieren. kern/llm.py
akzeptiert dafür ein Schlüsselwort _feature="…", das vor dem eigentlichen
API-Call herausgezogen wird; kern/usage.py schreibt daraus eine Zeile in die
Tabelle llm_usage (ts, feature, model, prompt_tokens,
completion_tokens) in ratslotse.sqlite. Die Erfassung ist best-effort: Sie
fängt jede Exception ab, damit Tracking niemals einen LLM-Aufruf kaputt macht —
unter Schreib-Konkurrenz paralleler Backfills bedeutet eine verlorene Zeile
lediglich eine leicht zu niedrige Statistik. Auch der Streaming-Pfad
(chat_stream) erfasst mit, indem er den Usage-Chunk anfordert.
Gekennzeichnet sind unter anderem protokoll_extraktion,
themen_klassifikation, committee_summary, ziel_bewertung,
entitaeten_ner, entitaeten_beschreibung, simple_summary,
interest_rating, impact_rating, fundstueck_story,
themenfeld_rueckblick, quiz_generation, quiz_verify,
qa_query_expansion und qa_antwort.
Kosten stehen nicht in der Datenbank, sondern werden aus Tokens ×
hinterlegtem Modellpreis gerechnet. Die Preistabelle PRICES in kern/usage.py
führt $ je 1 Mio. Tokens (Input, Output) je Modell und muss beim Wechsel auf ein
neues Modell ergänzt werden — ein unbekanntes Modell zählt mit 0,00 $.
Das Backend liefert das Ganze unter GET /admin/llm-usage (nur für Admins) aus
usage.dashboard(). Der Kosten-Tab im Admin-UI zeigt daraus:
| Kachel | Inhalt |
|---|---|
| Kosten diesen Monat | Summe vom Monatsersten bis heute, plus lineare Hochrechnung auf den vollen Monat |
| Budget-Ampel | Anteil am Monatsbudget in Prozent; ok < 80 %, warn ab 80 %, over ab 100 % |
| Täglicher Kostenverlauf | 30 Tage, lückenlos (Tage ohne Aufrufe erscheinen als 0) |
| Kostentreiber | Aggregat je Feature: Aufrufe, Tokens, geschätzte Kosten, verwendete Modelle, erster/letzter Aufruf — teuerste zuerst |
Das Budget kommt aus der Einstellung llm_budget_monthly (Default 40,0). Die
Ampel ist eine reine Anzeigeschwelle — sie drosselt nichts und schaltet
nichts ab.
Umgebungsvariablen
Abschnitt betitelt „Umgebungsvariablen“Die .env liegt ausschließlich auf dem Server und wird vom Deploy nicht
überschrieben. Cron-Skripte laden sie beim Start per load_dotenv, das Backend
über pydantic-settings.
LLM & OpenRouter
Abschnitt betitelt „LLM & OpenRouter“| Variable | Wofür | Pflicht | Default |
|---|---|---|---|
OPENROUTER_API_KEY |
Zugang zu allen LLM-Aufrufen (ADR 0001) | ja | — |
NWZ_OPENROUTER_ROUTING |
Provider-Routing (DSGVO) an/aus; off ist der Notausschalter (ADR 0002) |
nein | on |
NWZ_OPENROUTER_IGNORE |
Kommaliste ausgeschlossener Provider-Slugs | nein | deepseek,baidu,streamlake,siliconflow,alibaba |
NWZ_OPENROUTER_ZDR |
Zero-Data-Retention verlangen — für Aufrufe mit Nutzereingaben; Features mit nur öffentlichen Daten stehen in kern/llm.py::OHNE_NUTZEREINGABE und laufen ohne ZDR. 0/false/off/no lockert es für alle |
nein | 1 |
NWZ_DEEPSEEK_MIN_MAX_TOKENS |
Untergrenze für max_tokens bei DeepSeek-Reasoning-Modellen |
nein | 24000 |
Modellwahl je Aufgabe
Abschnitt betitelt „Modellwahl je Aufgabe“Alle optional — greift keine Variable, gilt der Default aus dem Code.
| Variable | Aufgabe | Default |
|---|---|---|
COUNCIL_PROTOCOL_MODEL |
Protokoll-/Beschluss-Extraktion | deepseek/deepseek-v4-pro |
COUNCIL_PROTOCOL_MAX_CHARS |
Zeichen-Obergrenze je Protokoll-Prompt | 60000 |
COUNCIL_TOPIC_MODEL |
Themen-Klassifikation | deepseek/deepseek-v4-pro |
COUNCIL_GOAL_MODEL |
Bewertung gegen die Stadtziele | deepseek/deepseek-v4-pro |
COUNCIL_ENTITY_MODEL |
Entitäten-NER und -Beschreibungen | deepseek/deepseek-v4-pro |
COUNCIL_SIMPLE_MODEL |
„Einfach erklärt“-Kurzfassungen | deepseek/deepseek-v4-pro |
COUNCIL_INTEREST_MODEL |
Gesprächswert-Score | deepseek/deepseek-v4-pro |
COUNCIL_IMPACT_MODEL |
Tragweite-Score | openai/gpt-6-luna (Flex-Tarif) |
COUNCIL_FUNDSTUECK_MODEL |
Story zum „Fundstück des Tages“ | deepseek/deepseek-v4-pro |
COUNCIL_RECAP_MODEL |
Themenfeld-Rückblicke | deepseek/deepseek-v4-pro |
COUNCIL_QA_MODEL |
„Frag den Rat“: Antwort, vereinfachte Antwort, Deep-Research-Bericht, Partei-Meinungen — ohne ZDR (llm.ZDR_VERZICHT) |
openai/gpt-6-luna (Denkaufwand: Vorgabe des Anbieters) |
COUNCIL_QA_EXPAND_MODEL |
„Frag den Rat“: Frage-Analyse vor der Suche (mit ZDR); auch die Städte-Suchbegriffe | google/gemini-3.1-flash-lite |
COUNCIL_ASSISTANT_MODEL |
Lottis Erklärungen — ohne ZDR (llm.ZDR_VERZICHT) |
openai/gpt-6-luna (Denkaufwand: Vorgabe des Anbieters) |
COUNCIL_QUIZ_MODEL |
Quizfragen erzeugen | deepseek/deepseek-v4-pro |
COUNCIL_QUIZ_VERIFY_MODEL |
Verify-Pass über erzeugte Quizfragen | openai/gpt-4o-mini |
COUNCIL_QUIZ_APPEAL_MODEL |
Richter: benotet, wie reizvoll eine Quizfrage ist | wie COUNCIL_QUIZ_VERIFY_MODEL |
COUNCIL_EMBED_MODEL |
Embeddings (fastembed, lokal) | sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2 |
COUNCIL_RERANK_MODEL |
Reranker für die hybride Suche | jinaai/jina-reranker-v2-base-multilingual |
COUNCIL_ALIAS_MODEL |
Prüft Themen-Dubletten (council/aliases.py) |
deepseek/deepseek-v4-pro |
TOPIC_INTEL_MODEL |
Themen-Beschreibung und Relevanzprüfung (council/topic_intel.py) — bricht die COUNCIL_-Namenskonvention |
deepseek/deepseek-v4-pro |
Web-Backend
Abschnitt betitelt „Web-Backend“| Variable | Wofür | Pflicht | Default |
|---|---|---|---|
WEB_JWT_SECRET |
Signiergeheimnis der Session-Tokens | ja | dev-insecure-change-me — der Start bricht ab, solange der Default steht und COOKIE_SECURE an ist; bei ausgeschaltetem COOKIE_SECURE nur eine Warnung |
WEB_ADMIN_EMAIL |
Diese Adresse wird Admin, sobald sie registriert und ihre E-Mail bestätigt hat (nur solange es keinen Admin gibt; ohne Mail-Versand: scripts/grant_admin.py <adresse>); Fallback für Alarm- und Feedback-Mails |
nein | leer |
COOKIE_SECURE |
Session-Cookies nur über HTTPS | nein | true |
ACCESS_TOKEN_EXPIRE_MINUTES |
Laufzeit des Web-Cookie-Tokens | nein | 129600 (90 Tage) |
SESSION_RENEW_WITHIN_MINUTES |
Ab welcher Restlaufzeit die Sitzung sich still verlängert; 0 schaltet die Verlängerung ab |
nein | 64800 (45 Tage) |
APP_ACCESS_TOKEN_EXPIRE_MINUTES |
Laufzeit des Tokens nativer Apps | nein | 129600 (90 Tage) |
APP_MIN_BUILD |
Kleinste noch zugelassene iOS-Buildnummer; GET /api/app-config liefert sie beim App-Start aus |
nein | 0 (keine Sperre) |
APP_UPDATE_NOTICE |
Optionaler Hinweistext für native Builds, z. B. zu einem empfohlenen Update | nein | leer |
CORS_ORIGINS |
Kommaliste erlaubter Web-Origins (in Prod läuft das Frontend same-origin) | nein | http://localhost:3000 |
APP_CORS_ORIGINS |
Feste Origins der Capacitor-Apps, immer zusätzlich erlaubt | nein | capacitor://localhost,https://localhost |
APPLE_BUNDLE_ID |
Erlaubter aud-Wert von „Sign in with Apple“ in der nativen App |
nein | de.ratslotse.app |
APPLE_SERVICE_ID |
Services-ID für den Apple-Web-Flow (aud des Browser-Tokens). Leeren schaltet den Web-Login ab — genau das war der Fehler, den #328 behoben hat. |
nein | de.ratslotse.web |
APPLE_TEAM_ID |
Apple Developer Team-ID; für den Token-Widerruf bei Kontolöschung erforderlich | für App Store | – |
APPLE_KEY_ID |
Key-ID des „Sign in with Apple“-Schlüssels | für App Store | – |
APPLE_PRIVATE_KEY |
Inhalt des privaten .p8-Schlüssels als PEM; escaped \\n werden akzeptiert |
für App Store | – |
LLM_BUDGET_MONTHLY |
Monatsbudget für die Budget-Ampel (reine Anzeige) | nein | 40.0 |
DISABLE_RATE_LIMIT |
1 schaltet das Rate-Limiting ab (nur für Tests) |
nein | nicht gesetzt |
Datenbanken
Abschnitt betitelt „Datenbanken“| Variable | Wofür | Pflicht | Default |
|---|---|---|---|
RATSLOTSE_DB |
Pfad zur Konten-/Themen-Datenbank | nein | data/ratslotse.sqlite |
COUNCIL_DB |
Pfad zur Ratsdaten-Datenbank | nein | data/council.sqlite |
RATSLOTSE_SQLITE |
Abweichender Pfad für das Usage-Tracking (kern/usage.py). Achtung: kern/usage.py liest ausschließlich diese Variable, der ganze Rest des Projekts RATSLOTSE_DB. Wer die Datenbank per RATSLOTSE_DB verschiebt, nimmt das Kosten-Tracking nicht mit — es schreibt still am alten Ort weiter. Beide zusammen setzen. |
nein | data/ratslotse.sqlite |
SETUP_REMIND_AFTER_HOURS |
Wartezeit, bevor remind_setup.py an eine offene Einrichtung erinnert |
nein | 48 |
E-Mail & Benachrichtigung
Abschnitt betitelt „E-Mail & Benachrichtigung“| Variable | Wofür | Pflicht | Default |
|---|---|---|---|
RESEND_API_KEY |
Versand über Resend; fehlt er, wird E-Mail still übersprungen | nein | leer |
EMAIL_FROM |
Absender der Mails | nein | Ratslotse <noreply@ratslotse.de> |
APP_BASE_URL |
Basis-URL in Mail-Links | nein | https://ratslotse.de |
FEEDBACK_EMAIL |
Empfänger*in des Nutzer-Feedbacks; zugleich die Antwortadresse der Rückmeldungen an die absendende Person | nein | leer → WEB_ADMIN_EMAIL |
ALERT_EMAIL |
Empfänger*in der Cron-Alarme | nein | nicht gesetzt → WEB_ADMIN_EMAIL |
Push (APNs / FCM)
Abschnitt betitelt „Push (APNs / FCM)“Push wird pro Plattform still übersprungen, solange deren Variablen unvollständig sind.
| Variable | Wofür | Pflicht | Default |
|---|---|---|---|
APNS_KEY_P8 |
APNs-Signaturschlüssel (p8) | für APNs alle vier | — |
APNS_KEY_ID |
Key-ID des p8-Schlüssels | für APNs alle vier | — |
APNS_TEAM_ID |
Apple-Team-ID | für APNs alle vier | — |
APNS_TOPIC |
Push-Topic (Bundle-ID der App) | für APNs alle vier | — |
APNS_USE_SANDBOX |
1 spricht zuerst das Sandbox-Gateway an |
nein | nicht gesetzt |
FCM_PROJECT_ID |
Firebase-Projekt für Android-Push | für FCM beide | — |
FCM_CREDENTIALS |
Pfad zur Service-Account-Datei | für FCM beide | — |
Backup & Sonstiges
Abschnitt betitelt „Backup & Sonstiges“| Variable | Wofür | Pflicht | Default |
|---|---|---|---|
BACKUP_RSYNC_TARGET |
Ziel des Off-Site-Mirrors; leer = kein Mirror | nein | leer |
BACKUP_RSYNC_SSH_PORT |
SSH-Port des Backup-Ziels | nein | 22 |
FASTEMBED_CACHE_PATH |
Persistenter Modell-Cache von fastembed (ohne ihn liegt er im Temp-Verzeichnis und ist nach einem Neustart weg) — von der Bibliothek selbst gelesen, nicht vom Repo-Code | nein | Vorgabe der Bibliothek |
Heim-Proxy gegen Adress-Sperren
Abschnitt betitelt „Heim-Proxy gegen Adress-Sperren“Manche Ziele sperren den ganzen Hetzner-Adressbereich: das Geoportal der
Stadt (gisportal4ol.oldenburg.de) schließt Verbindungen ohne Antwort,
YouTube blockt Rechenzentrums-Bereiche generell. Beides trifft jeden
Hetzner-Kunden, nicht diesen Server, und beides löst sich, sobald die
Anfrage von einem Privatanschluss kommt.
Dafür hängen Prod und Dev seit 09/2026 in einem Tailscale-Netz zusammen mit
einem Synology-NAS, auf dem ein SOCKS5-Proxy als Container läuft — erreichbar
nur über das Tailnet, nicht aus dem Internet. Zwei Variablen in der .env
schalten den Umweg ein:
RATSLOTSE_PROXY_URL=socks5h://nutzer:passwort@100.118.52.13:1080RATSLOTSE_PROXY_HOSTS=gisportal4ol.oldenburg.de,youtube.comNur die genannten Hosts und ihre Subdomains nehmen den Umweg (kern/proxy.py);
alles andere bleibt direkt. Ohne die Variablen ändert sich nichts. Es gibt
keinen stillen Rückfall auf „direkt“: Antwortet der Proxy nicht, scheitert
der Abruf laut, wie vorher an der Sperre — und der Wochenlauf meldet den
Schritt.
Wer den Umweg braucht: scripts/fetch_bplan_outlines.py (Bebauungsplan-
Umringe, wöchentlich) und der Video-Abruf per yt-dlp in council/videos.py.
Ein neues Ziel kommt in RATSLOTSE_PROXY_HOSTS und ruft an der Aufrufstelle
proxies_for(url) (requests) bzw. proxy_for(url) (Kommandozeile) auf.
Fallen: Der Geräteschlüssel eines Tailscale-Geräts läuft nach 180 Tagen ab —
in der Tailscale-Verwaltung für jedes Gerät „Disable key expiry“ setzen. Das
NAS war genau daran vier Wochen lang aus dem Netz gefallen. Die Tailscale-
Paketoberfläche auf dem NAS ist über QuickConnect gesperrt; Neuanmelden geht
über den Aufgabenplaner als root mit tailscale up --reset --authkey=….