docs+ops: ADRs 0006/0008, DDD-Bewertung, Zugriffsrechte, Smoke-Test, Cron-Scripts

ADRs:
- 0006 Embedding-Modell-Migration v3->v4 (#123)
- 0008 DDD-Lightweight-Migration (#136)

Analysen:
- ddd-bewertung.md (1237 Zeilen) — vollstaendige DDD-Analyse mit Tages-Roadmap
- protokoll-parser-v6-machbarkeit.md (418 Zeilen) — #106 Phase 2 Vorbereitung

Reference:
- zugriffsrechte.md — 63 Routes x 3 User-Status, UI-Sichtbarkeits-Matrix

Ops:
- scripts/deploy.sh — mit Uptime-Kuma-Wartungsmodus (#149)
- scripts/run-digest.sh — taeglicher Mail-Digest-Cron
- scripts/run-monitoring-scan.sh — Monitoring-Scan-Cron (noch nicht aktiv)
- scripts/smoke-test.sh — Gesamt-Funktionspruefung
- pytest.ini: integration/slow/e2e Markers, addopts not-integration

Tests/integration/: Live-Adapter-Tests + Frontend-XRef + Citation-Substring
                    + Wahlprogramm-Indexed (4 Live-Test-Suites, marker-opt-in)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dotty Dotter 2026-04-25 20:55:57 +02:00
parent 2902164eff
commit 2dec009b5c
10 changed files with 2147 additions and 1 deletions

View File

@ -0,0 +1,104 @@
# ADR 0006: Embedding-Modell-Migration text-embedding-v3 → v4
**Status:** accepted
**Datum:** 2026-04-11
**Issue:** #123
**Kontext:** #105 Clustering, #108 Empfehlungen
## Kontext
Bis 2026-04-11 wurden alle Wahlprogramm-Chunks (69 Programme, ~18k Chunks) mit
`text-embedding-v3` (1024 Dimensionen) indexiert. Für die geplanten Features
**#105 Antrag-Clustering** und **#108 Empfehlungen ähnlicher Anträge** brauchen
wir Embeddings für die Assessments selbst, nicht nur für Programm-Chunks.
Bei der Modellwahl stellte sich die Frage: dasselbe Modell für beide Seiten
oder darf eine Seite neuer sein?
**Kritisch:** v3- und v4-Embeddings liegen in unterschiedlichen Vektorräumen.
Cosine-Distance zwischen einem v3- und einem v4-Vektor ist mathematisch
gültig, aber semantisch Unsinn. Eine Vermischung macht die Citation-Binding-
Logik aus ADR 0001 kaputt und blockiert jede zukünftige Feature, die
Antrag-zu-Programm-Ähnlichkeit berechnet.
## Entscheidung
Vollständige Migration **beider Seiten** (Wahlprogramm-Chunks + Assessments)
auf `text-embedding-v4` (1024 Dimensionen, gleiches Default wie v3).
**Begründung:**
- Alibaba DashScope preist v3 und v4 **identisch** ($0.07 / 1M Tokens)
- v4 ist strikt besser auf MTEB (68.36 vs 63.39 overall, 59.30 vs 55.41 retrieval)
- v4 unterstützt 100+ Sprachen (v3: 50+), relevant für Zitate mit Fremdwörtern
- v4 bietet flexible Dimensionen 642048, wir bleiben aber auf 1024 (v3-kompatibel)
- Die einmaligen Reindex-Kosten belaufen sich auf **~$0.50** für alle Programme
- v3 wird vermutlich mittelfristig deprecated; wir vermeiden eine zweite Migration später
Die Alternative "v3 für Programme, v4 für Assessments" wurde verworfen, weil
sie die zukünftige Kompatibilität für Antrag-zu-Programm-Ähnlichkeit
permanent blockiert.
## Migrations-Strategie (Zero-Downtime)
**Zwei getrennte Settings** (`settings.embedding_model_write`,
`settings.embedding_model_read`) ermöglichen einen stufenweisen Switch ohne
Prod-Downtime:
| Phase | WRITE | READ | Zustand |
|---|---|---|---|
| 0 | v3 | v3 | Pre-Migration (alter Zustand) |
| 1 | **v4** | v3 | Code deployed, Reindex läuft im Hintergrund, Prod läuft weiter auf v3-Rows |
| 2 | v4 | **v4** | Reindex fertig, Flag geflippt, neue Queries nutzen v4-Rows |
| 3 | v4 | v4 | Cleanup: alte v3-Rows gelöscht |
**Schema-Änderungen:**
- `chunks`: neue Spalte `model TEXT NOT NULL DEFAULT 'text-embedding-v3'`
+ Index `idx_chunks_model`
- `assessments`: neue Spalten `summary_embedding BLOB`, `embedding_model TEXT`
**Reindex-Skript:** `app/reindex_embeddings.py` (Ausführung via
`docker exec gwoe-antragspruefer python -m app.reindex_embeddings`).
Schreibt v4-Rows parallel zu den v3-Rows, mit 100ms Rate-Limit zwischen
Calls (= max 10 req/sec). Bereits mit v4 indexierte Programme werden
übersprungen, damit das Skript idempotent ist und nach Abbruch nahtlos
fortgesetzt werden kann.
**Query-Pfad:** `find_relevant_chunks` filtert jetzt explizit
`WHERE model = ?` mit `EMBEDDING_MODEL_READ`. Query-Embeddings werden mit
demselben READ-Modell erzeugt (via neuer `model`-Parameter in
`create_embedding`), damit Query und Chunks im selben Vektorraum liegen.
## Konsequenzen
**Positiv:**
- Einheitlicher Vektorraum für Chunks und Assessments → #105, #108, und künftige
Ähnlichkeits-Features funktionieren out-of-the-box
- Bessere Retrieval-Qualität (MTEB +3.9 Punkte)
- Einmaliger Schritt, danach kein Mental-Load für Modell-Drift
**Negativ:**
- Reindex-Zeit: ~12h Wall-Time für alle 69 Programme (rate-limited)
- Kurzzeitig doppelter Storage (v3 + v4 Chunks parallel) bis Phase 3
- Assessment-Embedding-Generation adds ~100ms Latenz pro neuer Analyse (ein
zusätzlicher API-Call), der aber non-blocking fehlertolerant ist — Backfill
zieht später nach
**Neutral:**
- Die beiden Settings (`write` / `read`) bleiben langfristig im Code bestehen
als Infrastruktur für zukünftige Modell-Migrationen (v5, v6, …). Das Pattern
ist wiederverwendbar.
## Alternative: "v3 einfrieren"
Verworfen, weil v3 kein aktuell unterstütztes Flaggschiff mehr ist und
Deprecation wahrscheinlich ist. Besser jetzt migrieren, wenn noch beide
Modelle verfügbar sind, als später unter Zeitdruck.
## Alternative: "nur Assessments auf v4"
Verworfen wegen der Vektorraum-Fragmentierung (siehe Kontext).

View File

@ -21,6 +21,9 @@ und Konsequenzen. Format inspiriert von [Michael Nygard](https://cognitect.com/b
| [0002](0002-adapter-architecture.md) | Adapter-Pattern mit ParlamentAdapter-Basisklasse + Registry | accepted | 2026-04-10 | | [0002](0002-adapter-architecture.md) | Adapter-Pattern mit ParlamentAdapter-Basisklasse + Registry | accepted | 2026-04-10 |
| [0003](0003-citation-property-tests.md) | Sub-D Property-Verification: Zitate als Substring der zitierten PDF-Seite | accepted | 2026-04-10 | | [0003](0003-citation-property-tests.md) | Sub-D Property-Verification: Zitate als Substring der zitierten PDF-Seite | accepted | 2026-04-10 |
| [0004](0004-deployment-workflow.md) | Docker Compose Deploy mit DB-/Reports-Volume und SN-XML-Sonderpfad | accepted | 2026-04-10 | | [0004](0004-deployment-workflow.md) | Docker Compose Deploy mit DB-/Reports-Volume und SN-XML-Sonderpfad | accepted | 2026-04-10 |
| [0005](0005-keycloak-sso-with-dev-bypass.md) | Keycloak SSO mit Dev-Bypass-Fallback | accepted | 2026-04-10 |
| [0006](0006-embedding-model-migration-v3-to-v4.md) | Embedding-Modell-Migration text-embedding-v3 → v4 | accepted | 2026-04-11 |
| [0008](0008-ddd-lightweight-migration.md) | DDD-Lightweight-Migration (Repository, LLM-Port, Domain-Verhalten) | accepted | 2026-04-20 |
## Wann ADR, wann nicht ## Wann ADR, wann nicht

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,418 @@
# Plenarprotokoll-Parser v6 — Machbarkeits-Analyse
| | |
|---|---|
| **Status** | Exploration |
| **Datum** | 2026-04-20 |
| **Refs** | Issue #106 (Abstimmungsverhalten), #126 (BL-übergreifender Parser), MEMORY `reference_nrw_protokoll_parser.md` |
| **Autor** | Claude Opus 4.7 (1M) — strukturelle Exploration, kein Code |
## Zweck
Strukturierte Bewertung, ob ein Parser v6 für das Abstimmungsverhalten in
Plenarprotokollen sinnvoll ist — und wenn ja, in welcher Architektur.
Entscheidungsgrundlage, **keine** Implementierungs-Vorgabe.
## 1. Bestandsaufnahme: v5 heute
### 1.1 Wo lebt der Parser?
**Befund:** Im Produktionscode (`webapp/app/`) existiert **kein**
`protokoll_parser.py` oder vergleichbares Modul. Grep über alle
`app/*.py` auf `parse_protokoll`, `Plenarprotokoll`, `MMP`, `Wer stimmt`,
`Damit ist der Antrag` liefert **null** Treffer in Produktions-Python —
nur ein inzidentelles `Plenarprotokoll` im Kommentar zu
`parlamente.py:986`.
Der v5-Parser ist also **POC-Code aus einer früheren Session**, der
nicht ins Repo gelangt ist. Die einzige belastbare Referenz ist die
Memory-Datei
`~/.claude/projects/…gwoe…/memory/reference_nrw_protokoll_parser.md`
(95 Zeilen, 12 Apr. 2026).
**Konsequenz:** v6 ist **kein Refactor**, sondern ein Neuaufbau. Das
reduziert Altlast-Risiko, bedeutet aber auch: es gibt keine
Baseline-Test-Fixtures im Repo, keine bestehenden Call-Sites, die
zu brechen wären. Saubere grüne Wiese.
### 1.2 Was v5 laut Memory kann
Aus `reference_nrw_protokoll_parser.md` (zur Orientierung, nicht als
Autorität — Memory ist 8 Tage alt):
**Input:** PDF-Text von `landtag.nrw.de/.../MMP{WP}-{N}.pdf`, nach
Worttrennungs-Auflösung (`-\s*\n\s*` → `""`) und
Whitespace-Normalisierung.
**Anchor-Phrasen für Ergebnis-Klassifikation:**
```
Damit ist der Antrag [Drucksache X] angenommen|abgelehnt
Damit ist der Gesetzentwurf [Drucksache X] angenommen|abgelehnt
Damit ist dieser Antrag Drucksache X angenommen|abgelehnt
Damit ist (diese|die) Überweisungsempfehlung … angenommen → überwiesen
Somit ist dieser Antrag Drucksache X abgelehnt
```
**Segment-Boundaries** (trennen Abstimmungen im selben TOP):
```
Damit kommen wir zur Abstimmung
Wir kommen (somit )?zur Abstimmung
Wir stimmen (?!zu)
Somit kommen wir (direkt )?zu den Abstimmungen
```
**Vote-Pattern** (Ja/Nein/Enthaltung):
- `Wer stimmt … zu? Das (ist|sind) [Fraktionen]` → Ja-Stimmer
- `Wer stimmt dagegen?` / `Wer lehnt … ab?` → Nein-Stimmer
- `Wer enthält sich?` / `Gibt es Enthaltungen?` → Enthaltungen
**Negations-Antworten** (= leere Liste): `niemand`, `Keine
Gegenstimmen`, `nicht der Fall`, `Auch nicht`.
**Sonderfälle:**
- Re-Vote (Präsident unterbricht): „Vielleicht sind sich dann alle
einig." → letzte Instanz im Segment zählt.
- Beschlussempfehlung-vs-Gesetzentwurf: abgestimmt wird über den
Gesetzentwurf, die Empfehlungs-DS ist `nicht_gesondert_abgestimmt`.
- Protokoll-Typo (MMP18-115): Anchor nennt falsche DS → Segment-Entry-DS
hat Vorrang (v6-Forderung).
- Petitions-Sammelüberweisung (Drucksache 18/33 = Übersicht) →
Einzelpetitionen haben **kein** Ergebnis.
### 1.3 Validierungs-Stand v5
| Protokoll | Treffer | Bemerkung |
|---|---|---|
| MMP18-119 (Training) | 19/19 | 100 % Precision **und** Recall |
| MMP18-115 | 10/32 | fehlende Anchor-Varianten |
| MMP18-110 | 17/6 | Sammel-Petitions-Einzelansprache (False Positives) |
| MMP18-100 | 8/25 | ähnlich wie MMP18-115 |
**Interpretation:** v5 ist **overfit auf MMP18-119**. Präzision bleibt
hoch (keine falschen Abstimmungs-Ergebnisse), aber der Recall-Einbruch
auf ~30-50 % schon **innerhalb** NRW zeigt: die Anchor-Liste deckt nicht
einmal NRW-interne Variation ab. Cross-BL wird drastisch schlechter.
## 2. Cross-BL-Strukturanalyse
### 2.1 Sample-Verfügbarkeit im Repo
**Befund:** **Keine** Plenarprotokoll-Samples im Repo.
Geprüfte Pfade:
- `webapp/tests/fixtures/` → leer (nur Sub-Dir-Marker, keine Dateien)
- `webapp/app/static/` → nur Wahlprogramm-PDFs
- `webapp/data/` → nur `gwoe-antraege.db` (SQLite)
- `antraege/` (Projekt-Root) → 80+ PDFs, aber alle Drucksachen
(Anträge), keine MMP-Protokolle
- `TEMP/` → HAR-Captures der Dokumentations-Frontends, keine PDFs
Keine einzige `MMP*.pdf` oder `PlPr*.pdf` im gesamten Projekt-Tree.
### 2.2 Strukturelle Annahmen aus Doku-Systemen
Ohne Samples ist eine echte Struktur-Analyse nicht möglich. Aus
`app/bundeslaender.py` und `app/parlamente.py` lassen sich nur
**Plausibilitäts-Hypothesen** ableiten:
| Familie | BL | Protokoll-Quirk (Hypothese) |
|---|---|---|
| OPAL | NRW | MMP-PDF, stereotyp (v5-Referenz) |
| StarWeb/portala | HE, SH, BB, RP, LSA, BE | Plenarprotokolle typischerweise als PDF mit Zweispalten-Layout |
| ParlDok | MV, HH, TH | eigene Redaktionsrichtlinie pro LT |
| PARiS | HB | Bürgerschaft, Kleinst-Parlament |
| Eigensystem | BY, SL, SN | je eigene Typografie |
| DIP (BUND) | BUND | Plenarprotokolle als **strukturiertes XML** verfügbar — Ausnahme! |
**Wichtig:** Die Adapter in `parlamente.py` liefern bisher **Drucksachen**
(Anträge), nicht Plenarprotokolle. Für Protokoll-Download müssten die
Adapter erweitert werden — das ist Teil des Scopes von #106.
### 2.3 Fraktions-Namen-Normalisierung — vorhanden
`app/parteien.py:187` bietet `normalize_partei(raw, *, bundesland=None)`,
das laut Docstring die vier Adapter-eigenen `_normalize_fraktion()`
ersetzt. Das ist **entscheidend**, weil Protokolle Fraktionen in ihrer
LT-spezifischen Schreibweise nennen (`BÜNDNIS 90/DIE GRÜNEN`,
`GRÜNE`, `B'90/Grüne`). Die Komponente ist da und getestet — v6 muss
sie nicht neu bauen.
## 3. Lösungs-Ansätze
### 3.1 Option A — Rule-based expand (v5 pro BL)
Pro Bundesland eine `abstimmung_rules_<bl>.yaml` mit BL-spezifischen
Anchors, Segment-Boundaries, Vote-Patterns. Ein generischer Engine-
Kern lädt die Rules und parst.
**Pro:**
- Deterministisch, reproduzierbar, offline.
- Null Laufzeit-Kosten, Millisekunden pro Protokoll.
- Präzision bleibt bei 100 % (nur bekannte Anchors matchen).
**Contra:**
- **Recall-Problem überträgt sich** linear auf 17 BL. v5 schafft in NRW
schon nur 30-50 % auf unbekannten Protokollen — 17-fach skaliert
heißt: pro BL eigene Overfit-Runde.
- Format-Drift zwischen Wahlperioden (neues Redaktionsteam, neuer
Stilführer) bricht still.
- Wartungs-Aufwand: geschätzt 2-3 Tage Reverse-Engineering pro BL, plus
~0,5 Tage Nachpflege pro WP-Wechsel.
**Aufwand:** 2-3 Tage pro BL × 17 = **34-51 Personentage** bis
Vollabdeckung. Pro BL-Nachpflege alle 4-5 Jahre.
**Risiko:** Stille Recall-Einbrüche, die nur durch Ground-Truth-Tests
entdeckt werden. Ohne kontinuierliches Fixture-Update degradiert die
Datenqualität.
### 3.2 Option B — LLM-Extraction pro Seite
Ein strukturiertes Prompt nimmt eine Protokoll-Seite (oder einen
10-Seiten-Block) und liefert JSON:
```json
{
"abstimmungen": [
{
"drucksache": "18/17492",
"titel": "…",
"ergebnis": "angenommen|abgelehnt|überwiesen",
"ja": ["CDU", "GRÜNE"],
"nein": ["SPD", "AfD", "FDP"],
"enthaltung": [],
"namentlich": false
}
]
}
```
**Pro:**
- Ein Prompt für alle 17 BL, Format-Drift-robust.
- Nutzt bestehende LLM-Pipeline (`analyzer.py`, Qwen-Plus).
- Neue BL kosten **null** Entwicklungs-Zeit nach Grund-Integration.
- Zitat-Binding-Pattern aus ADR 0001 direkt übertragbar (Post-LLM-Match
gegen Protokoll-Text, genau wie bei Wahlprogramm-Zitaten).
**Contra:**
- **Kosten:** ein Protokoll hat 120-200 Seiten. Bei 10 Seiten pro
Prompt-Call: ~15-20 Calls × ~0,03 €/Call bei Qwen-Plus = **0,45-0,60 €
pro Protokoll**. NRW-WP18 hat ~170 Plenarsitzungen → ~85 € allein für
NRW, ~1500 € für alle 17 BL × 1 WP. Erträglich, aber nicht trivial.
- **Laufzeit:** 15-20 Calls × 10 s = 2,5-3 min pro Protokoll.
- **LLM-Halluzinationen:** ADR 0001 zeigt, dass Zitate cross-mixen.
Hier: Gefahr, dass LLM ein Abstimmungs-Ergebnis halluziniert, das im
Protokoll nicht vorkommt. Citation-Binding gegen Protokoll-Text ist
Pflicht.
**Aufwand:** Grund-Integration 3-5 Tage (Prompt-Engineering + Citation-
Binding analog ADR 0001 + Cache-Layer, damit dasselbe Protokoll nicht
zweimal abgefragt wird). Pro BL **null** zusätzlich.
**Risiko:** LLM-Halluzinationen bei stillem Citation-Binding-Bug;
Kosten-Explosion wenn alle WPs voll nachgefahren werden.
### 3.3 Option C — Hybrid: Rules-Pre-Filter + LLM-Strukturierung
**Stufe 1 (Rules):** Ein universelles Regex-Set (wenige, stabile
Anchors) segmentiert das Protokoll in **Abstimmungs-Kandidat-Blöcke**.
Kandidat: Text zwischen zwei `Abstimmung|stimmen ab|stimmen … zu`-
Vorkommen.
**Stufe 2 (LLM):** Jeder Kandidat-Block (~500-2000 Tokens) wird an den
LLM gegeben mit dem Prompt „Extrahiere Drucksache, Ergebnis, Ja/Nein/
Enthaltung-Fraktionen. Nur was im Text steht."
**Stufe 3 (Verifier):** Jede extrahierte Fraktion wird gegen
`normalize_partei()` geprüft und gegen `BUNDESLAENDER[bl]
.landtagsfraktionen` gefiltert. Halluzinierte Fraktionen (nicht im LT
vertreten) werden gedroppt oder loggen einen Warn.
**Pro:**
- Kosten ~80 % niedriger als Option B (nur Kandidat-Blöcke, nicht
ganze Seiten).
- BL-übergreifend, weil Stufe 1 nur grobe Anchors braucht (`stimmen`,
`Abstimmung` als Lemma-Formen sind in allen dt. Parlamenten üblich).
- Verifier fängt die meisten Halluzinationen ab.
- Skaliert auf neue BL mit nahezu null Zusatz-Aufwand.
**Contra:**
- Drei Stufen = drei Stellen, an denen Bugs sein können.
- Stufe 1 kann Kandidaten übersehen (stille Recall-Lücke). Debuggable
über „kein Kandidat gefunden"-Log.
- Komplexer zu testen als reine Rules oder reines LLM.
**Aufwand:** 5-7 Tage Grund-Integration. Pro BL: 0,5 Tag (einmal
Kalibrierung der Stufe-1-Anchors mit einem Protokoll-Sample).
**Risiko:** Mittel. Komplexität ist höher, aber jede Stufe isoliert
testbar. Analog zur LLM-Citation-Binding-Architektur aus ADR 0001.
### 3.4 Bewertungs-Matrix
| Kriterium | A (Rules) | B (LLM-voll) | C (Hybrid) |
|---|---|---|---|
| Precision (erwartet) | 95-100 % | 85-95 % | 90-98 % |
| Recall (erwartet) | 30-60 % pro BL | 85-95 % | 80-92 % |
| Entwicklung (Tage) | 34-51 | 3-5 | 5-7 |
| Laufzeit-Kosten/Prot. | ~0 € | ~0,50 € | ~0,10 € |
| Skaliert auf neue BL | nein (linear) | ja (gratis) | ja (halbgratis) |
| Format-Drift-Robustheit | niedrig | hoch | mittel-hoch |
| Debug-Transparenz | hoch | niedrig | mittel |
| Offline-fähig | ja | nein | nein |
## 4. Entscheidungs-Baum
```mermaid
flowchart TD
Start([Issue #106: Abstimmungsverhalten]) --> Samples{Plenarprotokoll-<br/>Samples<br/>beschaffbar?}
Samples -->|nein| Stop1[STOP:<br/>Sample-Beschaffung zuerst]
Samples -->|ja, ≥3 BL| Scope{Scope<br/>klar?}
Scope -->|nur NRW, kurzfristig| OptA[Option A<br/>v5 härten auf NRW]
Scope -->|alle 17 BL| OptFamily{Kosten-Bereitschaft<br/>für LLM-Calls?}
OptFamily -->|nein, offline nötig| OptA_scaled[Option A:<br/>Rule-Engine pro BL<br/>34-51 Tage]
OptFamily -->|ja, 1500 €/WP OK| OptC[Option C Hybrid:<br/>5-7 Tage Grund<br/>+ 0,5 Tag/BL]
OptC --> POC[POC auf 2 BL:<br/>NRW + HE<br/>Ground-Truth-Set je 3 Prot.]
POC --> Eval{Recall<br/>≥ 80 % auf HE?}
Eval -->|ja| Rollout[Rollout auf 17 BL]
Eval -->|nein| Rethink[LLM-Prompt iterieren<br/>oder auf B eskalieren]
OptA_scaled --> NRW_first[NRW zuerst,<br/>dann nach Output-Ranking]
```
## 5. Empfehlung
### 5.1 Familie
**Option C (Hybrid)** als Primärweg. Begründung:
1. Die bestehende Architektur passt: `analyzer.py` nutzt schon LLM,
ADR 0001 liefert das Binding-Pattern, `parteien.py:normalize_partei`
gibt den Verifier.
2. Option A skaliert nicht auf 17 BL ohne Personen-Aufwand, den das
Projekt realistisch nicht hat (siehe Issue-Backlog).
3. Option B hat das Halluzinations-Risiko und Kosten ohne Gewinn
gegenüber C.
### 5.2 Minimum-Viable-Scope
1. **Protokoll-Download-Erweiterung der Adapter:** `get_plenarprotokoll
(wp, sitzung)` als optionale Methode. Zuerst nur NRW + 1 weiteres BL
(Vorschlag: BUND, weil dort XML statt PDF → erleichtert
Text-Extraktion).
2. **Stufe-1-Anchors** als einzige YAML-Datei (nicht pro BL), mit den
~5 stabilsten Formulierungen (`stimmen ab`, `Abstimmung über`,
`wer stimmt zu`, `wer ist dagegen`, `wer enthält sich`).
3. **Stufe-2-Prompt** mit Zitat-Binding-Anforderung (jede Fraktion
muss als Exakt-Substring im Kandidat-Block vorkommen).
4. **Stufe-3-Verifier** via `normalize_partei` und
`BUNDESLAENDER[bl].landtagsfraktionen`.
5. **Persistenz:** neue Tabelle `abstimmungen (drucksache, wp, sitzung,
ergebnis, ja_fraktionen, nein_fraktionen, enthaltung_fraktionen,
source_url, extracted_at, extractor_version)`.
### 5.3 Abbruch-Kriterien
- **Stop 1:** Wenn POC auf 2 BL nach 7 Tagen unter 70 % Recall bleibt,
ist C nicht erreicht. Fallback: B (voller Seiten-Scan) oder Parken.
- **Stop 2:** Wenn Nutzer-Interview zeigt, dass Abstimmungsverhalten
pro Drucksache **keinen Dashboard-Mehrwert** gegenüber dem bereits
aktiven Score hat, ist v6 unpriorisiert. Issue #106 hängt seit April
offen — die Frage „braucht das jemand?" ist vor Implementierung zu
klären.
- **Stop 3:** Wenn eine offene Datenquelle (z.B. `abgeordnetenwatch.de
/api`) Abstimmungsverhalten bereits strukturiert ausliefert, ist ein
eigener Parser obsolet → **Evaluations-Pflicht vor Bau**.
### 5.4 BL-Priorisierung
Nach CSV-Export `/api/auswertungen/export.csv` und Wichtigkeit:
1. **NRW** — größter Output, v5 vorhanden
2. **BUND** — XML statt PDF, einfachste Implementierung, hoher Impact
3. **HE, BY, BW** — große Flächenländer, hohe Drucksachen-Zahl
4. **BE, HH, HB** — Stadtstaaten, geringer Protokoll-Umfang, guter
Cross-Check für den Hybrid
5. Rest nach Output-Ranking aus Dashboard
## 6. Sample-Beschaffungs-Plan
Da im Repo keine Plenarprotokolle liegen, für den POC zuerst manuell
ziehen. Pro BL 1 aktuelles Protokoll:
| BL | Quelle | Format |
|---|---|---|
| NRW | `landtag.nrw.de/portal/WWW/dokumentenarchiv/Dokument/MMP{WP}-{N}.pdf` | PDF |
| BUND | `bundestag.de/services/opendata` (XML-Feed) | **XML** |
| BY | `bayern.landtag.de/parlament/dokumente/plenarprotokolle` | PDF |
| BW | `landtag-bw.de/home/dokumente/plenarprotokolle.html` | PDF |
| BE | `parlament-berlin.de/dokumente/open-data` (XML verfügbar) | XML/PDF |
| BB | `parlamentsdokumentation.brandenburg.de` (portala) | PDF |
| HB | `paris.bremische-buergerschaft.de/starweb/paris` | PDF |
| HH | `buergerschaft-hh.de/parldok` | PDF |
| HE | `starweb.hessen.de/portal` | PDF |
| MV | `dokumentation.landtag-mv.de` (ParlDok) | PDF |
| NI | `nilas.niedersachsen.de` (HAR-Capture nötig, siehe MEMORY) | PDF |
| RP | `opal.rlp.de` (portala) | PDF |
| SL | `landtag-saar.de` (Umbraco) | PDF |
| SN | `edas.landtag.sachsen.de` (XML-Export, siehe `project_sn_xml_export.md`) | **XML** |
| LSA | `padoka.landtag.sachsen-anhalt.de` (PARDOK) | PDF |
| SH | `lissh.lvn.parlanet.de/cgi-bin/starfinder/0` | PDF |
| TH | `parldok.thueringer-landtag.de` | PDF |
Speicherung unter `webapp/tests/fixtures/protokolle/<bl>/` (git-lfs
oder außerhalb des Repos, je nach Dateigröße). Pro Protokoll eine
`<protokoll>.expected.json` mit Ground-Truth-Abstimmungen als
Fixture.
**Minimum für POC:** NRW (MMP18-119 als Regression), BUND (XML als
Gegenprobe), HE (StarWeb als Cross-BL). Drei Protokolle × ~20-30
Abstimmungen = ~60-90 Ground-Truth-Items für Precision/Recall.
## 7. Offene Fragen für Tobias
1. **Bedarfs-Validierung:** Wer konsumiert das Abstimmungsverhalten
konkret im Dashboard? Gibt es eine UI-Spec dafür, oder ist das
gedachte Feature? Issue #106 nennt keine konkreten Konsumenten.
2. **LLM-Kosten:** Ist ~1500 € Budget für die 17-BL-Erstbefüllung einer
WP vertretbar? Oder muss der Rollout auf eine WP pro Jahr gedeckelt
werden?
3. **Alternative Datenquelle:** Wurde `abgeordnetenwatch.de/api`
geprüft? Die liefern auf Bundesebene strukturierte
Abstimmungsdaten und haben teilweise Landesabdeckung. Wenn ja, ist
v6 für diese BL obsolet.
4. **v5-POC-Code:** Existiert der Code noch auf der Festplatte (in
einem früheren Session-Worktree)? Ein Fundstück würde 1-2 Tage
Re-Implementations-Arbeit sparen.
## 8. Nächster Schritt (empfohlen)
1. **Vor jeder Zeile Code:** Beantworte Fragen 1 und 3 oben. Das
entscheidet, ob v6 überhaupt startet.
2. **Wenn ja:** POC auf NRW + BUND + HE mit Option C, Sample-Zug
manuell. 7 Tage Timebox.
3. **Wenn POC erfolgreich (Recall ≥ 80 %):** ADR für v6 schreiben
(analog 0001/0002), Rollout-Plan.
4. **Wenn POC scheitert:** Zurück zu Option A nur für NRW, v5-Recall-Lücken schließen, #106 auf NRW-only beschränken und #126 schließen
als „nicht wirtschaftlich".
## 9. Verifikations-Checkliste
- [x] v5-Code lokal gesucht → nicht im Repo
- [x] MEMORY-Referenz gelesen (95 Zeilen) — v5-Pattern dokumentiert
- [x] Sample-Inventar: **null Protokoll-Samples im Repo**
- [x] BL-Adapter-Architektur (ADR 0002) verstanden — `ParlamentAdapter`
hat aktuell **keine** `get_plenarprotokoll`-Methode
- [x] Fraktions-Normalisierung existiert (`parteien.py:187
normalize_partei`)
- [x] Kosten-Schätzung für Option B/C kalkuliert
- [x] Drei Optionen bewertet, Matrix + Mermaid-Diagramm
- [x] Abbruch-Kriterien formuliert
- [x] Sample-Beschaffungs-Plan für alle 17 BL
---
**Nicht in diesem Dokument:** Konkreter Code, Prompt-Vorlagen, ADR-Text
für v6. Alles nächste Phase nach Go/No-Go-Entscheidung von Tobias.

View File

@ -0,0 +1,140 @@
# Zugriffsrechte & User-Status
Abgeleitet direkt aus dem Code (`app/main.py`, `app/auth.py`, Templates). Stand des Generats: 2026-04-20.
## Drei User-Status
| Status | Signal | Guard im Code |
|---|---|---|
| **Gast** (`anonymous`) | Kein gültiger `access_token`-Cookie | — |
| **Registriert** (`authenticated`) | Gültiger Keycloak-JWT, Rolle beliebig | `Depends(require_auth)` |
| **Admin** | Keycloak-JWT mit Rolle `admin` oder `gwoe-admin` | `Depends(require_admin)` |
Zwei Sonder-Modi:
- **Dev-Modus** (`AUTH_ENABLED=false` in .env): jede Anfrage wird als Anonymous+Admin ausgegeben, sämtliche Guards fallen. Nur lokal, nie in Prod. Siehe ADR 0005.
- **Approval-pending**: Nutzer:innen, die über `/api/auth/register` angelegt sind aber noch nicht von einem Admin via `/api/auth/approve-user` freigeschaltet wurden, können sich einloggen, aber keine `require_auth`-Features nutzen (Keycloak verweigert Token-Ausgabe). Siehe `docs/how-to/keycloak-setup.md`.
## Endpoint × Status-Matrix (63 Routes)
### Admin-only (5)
Nur Nutzer:innen mit Rolle `admin`/`gwoe-admin` erreichen diese:
| Methode | Pfad | Zweck |
|---|---|---|
| POST | `/api/batch-analyze` | Batch-Bewertung starten |
| POST | `/api/programme/index` | Wahlprogramm indexieren |
| POST | `/api/auth/approve-user` | User-Freischaltung |
| GET | `/api/auth/pending-users` | Liste offener Freischaltungs-Anträge |
| DELETE | `/api/assessment/delete` | Bewertung löschen (für Re-Analyse) |
### Authenticated (8)
Jede:r eingeloggte:r Nutzer:in:
| Methode | Pfad | Zweck |
|---|---|---|
| POST | `/analyze` | Freitext-Upload bewerten |
| POST | `/api/analyze-drucksache` | Antrag aus Landtag bewerten |
| POST | `/api/bookmark` | Merklisten-Eintrag toggeln |
| POST | `/api/comment` | Kommentar anlegen |
| DELETE | `/api/comment/{id}` | Eigenen Kommentar löschen |
| POST | `/api/subscriptions` | E-Mail-Abo anlegen |
| DELETE | `/api/subscriptions/{id}` | E-Mail-Abo löschen |
| POST | `/api/vote` | Antrag-Bewertung votieren |
### Optional-User (5)
Funktionieren auch ohne Login, aber personalisieren mit User-Daten wenn eingeloggt:
| Methode | Pfad | Verhalten |
|---|---|---|
| GET | `/api/auth/me` | Gibt Auth-Status zurück |
| GET | `/api/bookmarks` | Liste eigener Bookmarks (wenn eingeloggt), sonst `[]` |
| GET | `/api/comments?drucksache=X` | Öffentliche + eigene Kommentare |
| GET | `/api/subscriptions` | Eigene Abos |
| GET | `/api/votes?drucksache=X` | Alle Votes + eigenes Vote-Flag |
### Public (45)
Alle Lese-Endpoints und statische Seiten sind offen. Darunter u.a.:
- **Seiten:** `/`, `/antrag/{ds}`, `/classic`, `/auswertungen`, `/methodik`, `/quellen`, `/impressum`, `/datenschutz`, `/health`, `/v2/{merkliste,tags,cluster,neu,batch}`
- **API-Listen:** `/api/assessments`, `/api/assessment`, `/api/clusters`, `/api/bundeslaender`, `/api/programme`, `/api/search`, `/api/search-landtag`, `/api/feed.xml`, `/api/wahlprogramm-cite`
- **Auswertungen:** `/api/auswertungen/{matrix,zeitreihe,themen-matrix,export.csv,export.json}`
- **Auth-Flow:** `/api/auth/login`, `/api/auth/register`, `/api/auth/callback`, `/api/auth/login-url`, `/api/auth/refresh`, `/unsubscribe/{sub}/{token}`
- **Jobs:** `/api/analyze-drucksache`-Ergebnisse via `/status/{job_id}`, `/result/{job_id}`, `/result/{job_id}/pdf`, `/api/queue/status`
Das heißt: **Lesen und Navigieren braucht keinen Account**. Erst Aktionen (Merken, Kommentieren, Bewerten, neue Analyse starten) erfordern Login.
## UI-Sichtbarkeit — was sieht wer
### v2-Frontend (`/`, `/antrag/*`, `/v2/*`)
| UI-Element | Gast | Registriert | Admin |
|---|:-:|:-:|:-:|
| Sidebar-Gruppe „Lesen" (Durchsuchen / Merkliste / Tags / Cluster) | ✓ | ✓ | ✓ |
| Sidebar-Gruppe „Prüfen" (Neuer Antrag / Batch-Analyse) | ✓ Links | ✓ funktional | ✓ funktional |
| Sidebar-Gruppe „Daten" (Auswertungen / Export / Feed) | ✓ | ✓ | ✓ |
| Sidebar-Gruppe „Administration" (Freischaltungen / Queue / Abos) | — | — | ✓ (via `{% if is_admin %}` in `base.html:61`) |
| Topbar „Klassische Ansicht" + Theme-Toggle | ✓ | ✓ | ✓ |
| `/v2/merkliste` Bookmark-Liste | Login-CTA | eigene Liste | eigene Liste |
| Bookmark-Stern auf Antragsdetail | Login-CTA | ✓ | ✓ |
| Kommentar-Form | Login-CTA | ✓ | ✓ |
| Vote-Buttons | Login-CTA | ✓ | ✓ |
| Re-Analyze-Button | — | ✓ (nach Ablauf) | ✓ jederzeit |
| Delete-Assessment-Button | — | — | ✓ |
### Classic-Frontend (`/classic`)
| UI-Element | Gast | Registriert | Admin |
|---|:-:|:-:|:-:|
| Listenansicht + Detail | ✓ | ✓ | ✓ |
| Hamburger → Anmelden/Registrieren | ✓ (öffnet Modal) | — | — |
| Hamburger → Auswertungen / Quellen / Methodik | ✓ | ✓ | ✓ |
| Merkliste-Tab | ✓ (localStorage, gerätegebunden) | ✓ (synced mit Server) | ✓ |
| Kommentare anlegen | Login-CTA | ✓ | ✓ |
| Admin-Tab | — | — | ✓ (Freischaltungen, Queue, Batch) |
## Login-/Auth-Flows
Zwei Varianten koexistieren:
### Direct Access Grant (Default in v2 + `/classic`-Modal)
1. User klickt „Anmelden" → Modal öffnet
2. POST `/api/auth/login` mit `username`+`password`
3. Server ruft Keycloak `grant_type=password` gegen Client `gwoe-antragspruefer`
4. Setzt `access_token` (HttpOnly-Cookie) + `rt` (Refresh-Token, Path `/api/auth/refresh`)
5. Modal schließt, UI refreshed via `/api/auth/me`
**Voraussetzung:** Keycloak-Client `gwoe-antragspruefer` hat `directAccessGrantsEnabled: true` (ist gesetzt seit 2026-04-20).
### OIDC Redirect (Fallback)
`/api/auth/login-url` → Keycloak-Login-Seite → `/api/auth/callback` → Cookie gesetzt → Redirect auf `/`.
Wurde mit der Direct-Access-Variante überflüssig, bleibt für Notfall erhalten.
## Admin-Rollen definieren
Im Keycloak-Admin:
1. Realm `collaboration`**Roles** → Rolle `admin` anlegen (oder `gwoe-admin`)
2. User → dem jeweiligen Nutzer die Rolle zuweisen
3. Code prüft in `auth.py:220`: `"admin" in roles or "gwoe-admin" in roles`
Kein Fein-Granular-Rollen-Modell vorgesehen — `admin` ist alles-oder-nichts.
## Dev-Bypass
In `.env`: `AUTH_ENABLED=false` setzt alle Guards auf Bypass. `require_auth` gibt `Dev-Modus`-User zurück (`sub=anonymous`, `roles=[]`), `require_admin` gibt `roles=["admin"]`. Nur für lokale Entwicklung ohne Keycloak-Stack. In Prod immer auf `true`.
## Änderung gegenüber alter Doku
`docs/reference/api.md` hatte eine stale „Auth"-Spalte mit „Keycloak (geplant)". Diese Doku ersetzt sie inhaltlich — das api.md könnte zusammengelegt oder auf diese Seite verlinken.
## Wartungshinweis
Diese Doku wird nicht automatisch generiert. Bei neuen Routes / Guards manuell nachpflegen oder via `docs/reference/scan-zugriffsrechte.py` (TODO: hat bisher keiner geschrieben) regenerieren.

View File

@ -10,4 +10,5 @@ filterwarnings =
markers = markers =
integration: live HTTP/PDF/LLM/DB tests, slow, may flake on backend issues integration: live HTTP/PDF/LLM/DB tests, slow, may flake on backend issues
slow: tests that take > 5s, opt out via -m "integration and not slow" slow: tests that take > 5s, opt out via -m "integration and not slow"
addopts = -m "not integration" e2e: Playwright browser tests against live site, requires chromium
addopts = -m "not integration and not e2e"

85
scripts/deploy.sh Executable file
View File

@ -0,0 +1,85 @@
#!/bin/bash
# Deploy-Script mit Uptime-Kuma-Wartungsmodus
# Usage: ./scripts/deploy.sh [files...]
# Ohne Argumente: alles deployen
#
# Setzt den GWÖ-Monitor in Uptime Kuma auf Wartung,
# deployed, und aktiviert den Monitor wieder.
#
# Benötigt: UPTIME_KUMA_USER + UPTIME_KUMA_PASS in ~/.env oder als ENV
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
SERVER="vserver"
REMOTE_DIR="/opt/gwoe-antragspruefer"
UPTIME_KUMA_URL="https://status.toppyr.de"
MONITOR_ID=9 # GWÖ-Antragsprüfer
# Credentials laden
if [ -f ~/.env ]; then
source ~/.env
fi
cd "$PROJECT_DIR"
echo "=== GWÖ-Antragsprüfer Deploy ==="
# 1. Uptime Kuma auf Wartung setzen
if [ -n "${UPTIME_KUMA_USER:-}" ] && [ -n "${UPTIME_KUMA_PASS:-}" ]; then
echo "⏸ Setze Monitor auf Wartung..."
python3 -c "
from uptime_kuma_api import UptimeKumaApi
api = UptimeKumaApi('$UPTIME_KUMA_URL')
api.login('$UPTIME_KUMA_USER', '$UPTIME_KUMA_PASS')
api.pause_monitor($MONITOR_ID)
api.disconnect()
print(' Monitor pausiert')
" 2>/dev/null || echo " (Uptime Kuma nicht erreichbar, überspringe)"
else
echo "⚠ UPTIME_KUMA_USER/PASS nicht gesetzt, überspringe Wartungsmodus"
fi
# 2. Build + Deploy
if [ $# -gt 0 ]; then
# Spezifische Files
echo "📦 Packe: $@"
tar czf /tmp/gwoe-deploy.tar.gz "$@"
else
# Alles
echo "📦 Packe gesamtes Projekt (ohne venv/data/reports)..."
tar czf /tmp/gwoe-deploy.tar.gz \
--exclude='venv' --exclude='__pycache__' \
--exclude='data' --exclude='reports' --exclude='.env' .
fi
echo "🚀 Upload + Build..."
scp /tmp/gwoe-deploy.tar.gz "$SERVER:/tmp/"
ssh "$SERVER" "cd $REMOTE_DIR && tar xzf /tmp/gwoe-deploy.tar.gz && docker compose up -d --build" 2>&1 | tail -5
# 3. Warte auf Health
echo "⏳ Warte auf Health-Check..."
for i in $(seq 1 30); do
code=$(curl -sS -o /dev/null -w "%{http_code}" --max-time 3 "https://gwoe.toppyr.de/health" 2>/dev/null || echo "000")
if [ "$code" = "200" ]; then
echo "✅ Health OK nach ${i}s"
break
fi
sleep 1
done
# 4. Uptime Kuma wieder aktivieren
if [ -n "${UPTIME_KUMA_USER:-}" ] && [ -n "${UPTIME_KUMA_PASS:-}" ]; then
echo "▶ Reaktiviere Monitor..."
python3 -c "
from uptime_kuma_api import UptimeKumaApi
api = UptimeKumaApi('$UPTIME_KUMA_URL')
api.login('$UPTIME_KUMA_USER', '$UPTIME_KUMA_PASS')
api.resume_monitor($MONITOR_ID)
api.disconnect()
print(' Monitor aktiv')
" 2>/dev/null || echo " (Uptime Kuma nicht erreichbar)"
fi
echo "=== Deploy abgeschlossen ==="

19
scripts/run-digest.sh Executable file
View File

@ -0,0 +1,19 @@
#!/bin/bash
# Runs the daily email digest (Issue #124).
# Install as host cron:
# crontab -e
# 0 7 * * * /opt/gwoe-antragspruefer/scripts/run-digest.sh >> /var/log/gwoe-digest.log 2>&1
set -euo pipefail
CONTAINER=gwoe-antragspruefer
# Nur ausführen wenn Container läuft
if ! docker ps --format '{{.Names}}' | grep -q "^${CONTAINER}$"; then
echo "$(date -Iseconds) SKIP — ${CONTAINER} is not running"
exit 0
fi
echo "$(date -Iseconds) START daily digest"
docker exec "$CONTAINER" python -m app.mail
echo "$(date -Iseconds) END"

27
scripts/run-monitoring-scan.sh Executable file
View File

@ -0,0 +1,27 @@
#!/bin/bash
# Runs the daily monitoring scan for new Drucksachen (Issue #135).
# Scannt alle aktiven Bundesländer auf neue Drucksachen (nur Metadaten,
# kein PDF-Download, kein LLM-Call) und verschickt einen Mail-Digest.
#
# Install as host cron (nach Review durch Parent — Issue #135):
# crontab -e
# 30 6 * * * /opt/gwoe-antragspruefer/scripts/run-monitoring-scan.sh >> /var/log/gwoe-monitoring.log 2>&1
#
# Empfänger-Adresse kann als erstes Argument übergeben werden:
# run-monitoring-scan.sh mail@tobiasroedel.de
# Default: mail@tobiasroedel.de
set -euo pipefail
CONTAINER=gwoe-antragspruefer
RECIPIENT="${1:-mail@tobiasroedel.de}"
# Nur ausführen wenn Container läuft
if ! docker ps --format '{{.Names}}' | grep -q "^${CONTAINER}$"; then
echo "$(date -Iseconds) SKIP — ${CONTAINER} is not running"
exit 0
fi
echo "$(date -Iseconds) START monitoring scan (recipient: ${RECIPIENT})"
docker exec "$CONTAINER" python -m app.monitoring "$RECIPIENT"
echo "$(date -Iseconds) END"

112
scripts/smoke-test.sh Executable file
View File

@ -0,0 +1,112 @@
#!/bin/bash
# Gesamt-Funktionsprüfung — Smoke-Test für GWÖ-Antragsprüfer Live-System.
# Usage: ./scripts/smoke-test.sh [base-url]
# Default: https://gwoe.toppyr.de
set -uo pipefail
BASE="${1:-https://gwoe.toppyr.de}"
PASS=0; FAIL=0; TOTAL=0
check() {
local name="$1" expected="$2" url="$3" extra="${4:-}"
TOTAL=$((TOTAL+1))
local code
code=$(curl -s -o /dev/null -w "%{http_code}" $extra "$BASE$url" 2>&1 || echo "000")
if [[ "$code" == "$expected" ]]; then
printf " ✓ %-40s %s\n" "$name" "$code"
PASS=$((PASS+1))
else
printf " ✗ %-40s expected=%s got=%s\n" "$name" "$expected" "$code"
FAIL=$((FAIL+1))
fi
}
contains() {
local name="$1" pattern="$2" url="$3"
TOTAL=$((TOTAL+1))
local body
body=$(curl -s "$BASE$url" 2>&1)
if echo "$body" | grep -qE "$pattern"; then
printf " ✓ %-40s pattern=%s\n" "$name" "$pattern"
PASS=$((PASS+1))
else
printf " ✗ %-40s pattern=%s NOT FOUND\n" "$name" "$pattern"
FAIL=$((FAIL+1))
fi
}
echo "================================================================"
echo " GWÖ-Antragsprüfer Smoke-Test gegen $BASE"
echo " $(date -Iseconds)"
echo "================================================================"
echo
echo "[1] Hauptseiten erreichbar (alle 200)"
check "v2 Default /" "200" "/"
check "v2 Detail (echte DS)" "200" "/antrag/21/754S"
check "Classic /classic" "200" "/classic"
check "/auswertungen" "200" "/auswertungen"
check "/methodik" "200" "/methodik"
check "/quellen" "200" "/quellen"
check "/impressum" "200" "/impressum"
check "/datenschutz" "200" "/datenschutz"
check "/v2/merkliste" "200" "/v2/merkliste"
check "/v2/tags" "200" "/v2/tags"
check "/v2/cluster" "200" "/v2/cluster"
check "/v2/landtag-suche" "200" "/v2/landtag-suche"
check "/v2/neu" "200" "/v2/neu"
check "/v2/batch" "200" "/v2/batch"
check "/health" "200" "/health"
echo
echo "[2] API-Endpoints (öffentlich)"
check "/api/assessments" "200" "/api/assessments"
check "/api/bundeslaender" "200" "/api/bundeslaender"
check "/api/clusters" "200" "/api/clusters"
check "/api/queue/status" "200" "/api/queue/status"
check "/api/programme" "200" "/api/programme"
check "/api/feed.xml" "200" "/api/feed.xml"
check "/api/search?q=klima" "200" "/api/search?q=klima"
check "/api/auswertungen/matrix" "200" "/api/auswertungen/matrix"
check "/api/auswertungen/export.csv" "200" "/api/auswertungen/export.csv"
check "/api/auth/me (unauth)" "200" "/api/auth/me"
echo
echo "[3] API-Endpoints (Auth-Schutz)"
check "POST analyze-drucksache (no auth)" "401" "/api/analyze-drucksache" "-X POST -d ''"
check "POST bookmark (no auth)" "401" "/api/bookmark" "-X POST"
check "POST comment (no auth)" "401" "/api/comment" "-X POST"
check "POST vote (no auth)" "401" "/api/vote" "-X POST"
echo
echo "[4] Statics (CSS, Fonts, Icons)"
check "v2 tokens.css" "200" "/static/v2/tokens.css"
check "v2 v2.css" "200" "/static/v2/v2.css"
check "Nunito-Sans woff2" "200" "/static/v2/fonts/nunito-sans-latin-variable.woff2"
check "Phosphor magnifying-glass" "200" "/static/v2/icons/phosphor/magnifying-glass.svg"
echo
echo "[5] Inhalts-Checks"
contains "v2 rendert AppShell" 'v2-shell' "/"
contains "v2 Sidebar vorhanden" 'v2-sidebar' "/"
contains "v2 hat Login-Button" 'v2-auth-control' "/"
contains "Detail rendert ScoreHero" 'big-num' "/antrag/21/754S"
contains "Detail rendert MatrixMini" 'matrix-mini' "/antrag/21/754S"
contains "Detail rendert Programm-Treue" 'Programm-Treue' "/antrag/21/754S"
contains "Detail rendert Voting" 'castVote|v2DetailCastVote' "/antrag/21/754S"
contains "Detail rendert Kommentare" 'v2-comments|loadComments' "/antrag/21/754S"
contains "Detail OG-Meta-Tags" 'og:image' "/antrag/21/754S"
contains "Cluster-API liefert JSON" 'clusters' "/api/clusters"
contains "Auswertungen-Matrix JSON" 'bundeslaender' "/api/auswertungen/matrix"
contains "Feed ist Atom" '<feed|application' "/api/feed.xml"
echo
echo "[6] Live-Suche NRW (durchlauf)"
check "/api/search-landtag (NRW)" "200" "/api/search-landtag?q=schule&bundesland=NRW"
echo
echo "================================================================"
echo " Ergebnis: $PASS/$TOTAL bestanden, $FAIL Fehler"
echo "================================================================"
exit $FAIL