diff --git a/docs/adr/0006-embedding-model-migration-v3-to-v4.md b/docs/adr/0006-embedding-model-migration-v3-to-v4.md
new file mode 100644
index 0000000..9142a9b
--- /dev/null
+++ b/docs/adr/0006-embedding-model-migration-v3-to-v4.md
@@ -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 64–2048, 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: ~1–2h 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).
diff --git a/docs/adr/index.md b/docs/adr/index.md
index 436ae3c..02603a9 100644
--- a/docs/adr/index.md
+++ b/docs/adr/index.md
@@ -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 |
| [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 |
+| [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
diff --git a/docs/analysen/ddd-bewertung.md b/docs/analysen/ddd-bewertung.md
new file mode 100644
index 0000000..cb2ce5a
--- /dev/null
+++ b/docs/analysen/ddd-bewertung.md
@@ -0,0 +1,1237 @@
+# Domain-Driven Design — Bewertung für den GWÖ-Antragsprüfer
+
+**Autor:** Claude Code (autonom)
+**Datum:** 2026-04-20
+**Issue:** #136
+**Status:** Analyse — keine Umsetzung
+
+---
+
+## TL;DR
+
+Das Repository ist eine *Technical-Layering*-Architektur (alle Models in einer
+Datei, alle DB-Zugriffe in einer Datei, alle Adapter in einer Datei …), die
+für ein Solo-Projekt dieser Größe **pragmatisch angemessen** ist. Voll-DDD
+würde 4-8 Wochen netto kosten und den Entwicklungsfluss verlangsamen, ohne
+dass die Domäne ausreichend komplex wäre, um das zu rechtfertigen.
+
+**Empfehlung: Option B (Lightweight)** — drei gezielte DDD-Prinzipien ohne
+Package-Split, 5-8 Tage netto. Details in Kapitel 7 und die konkrete
+Tages-Roadmap in Kapitel 10.
+
+---
+
+## 1. Ist-Zustand
+
+### 1.1 Modul-Inventar
+
+23 Dateien in `webapp/app/`, flach nebeneinander, technisch geschichtet nach
+Funktion. LOC gemessen mit `wc -l` am 2026-04-20:
+
+| Datei | LOC | Rolle | DDD-Sicht |
+|---|--:|---|---|
+| `main.py` | 1746 | FastAPI-App + Routes + Middleware | Interface + Application + Infrastructure (alles) |
+| `parlamente.py` | 3397 | Adapter für 17 Landtage | Domain + Infrastructure + Anti-Corruption-Layer |
+| `embeddings.py` | 1068 | Wahlprogramm-Chunks + Suche | Infrastructure |
+| `database.py` | 909 | SQLite-Persistenz | Infrastructure, von überall aufgerufen |
+| `report.py` | 647 | PDF-Rendering | Infrastructure |
+| `bundeslaender.py` | 480 | BL-Stammdaten | Domain-Katalog |
+| `wahlprogramme.py` | 447 | Registry der PDFs | Domain-Katalog + Infrastructure |
+| `analyzer.py` | 379 | LLM-Bewertung | Domain + Infrastructure vermischt |
+| `parteien.py` | 334 | Partei-Stammdaten | Domain-Katalog |
+| `monitoring.py` | 322 | Scan + Daily-Stats (#135) | Application + Infrastructure |
+| `clustering.py` | 312 | Embedding-Nähe-Graph | Domain-Algorithm + Infrastructure |
+| `queue.py` | 303 | Background-Worker | Infrastructure |
+| `auth.py` | 300 | Keycloak-OIDC | Infrastructure |
+| `reindex_embeddings.py` | 234 | CLI-Script | Application / CLI |
+| `auswertungen.py` | 227 | Aggregations-Views | Application |
+| `mail.py` | 220 | SMTP + Digest | Application + Infrastructure |
+| `models.py` | 180 | Pydantic-DTOs | Anämisches Domain-Model |
+| `drucksache_typen.py` | 88 | Typ-Normalisierung | Domain-Service |
+| `config.py` | 60 | Settings | Infrastructure |
+| `wahlperioden.py` | 52 | WP-Metadaten | Domain-Katalog |
+| `validators.py` | 47 | Input-Validatoren | Application |
+| `wahlprogramm_check.py` | 37 | Fehlende-Programme-Prüfung (#128) | Domain-Service |
+| `__init__.py` | 0 | | |
+| **gesamt** | **11789** | | |
+
+### 1.2 Konkrete DDD-Verletzungen (mit Zeilen-Referenzen)
+
+**A) Infrastructure-Leak in Domain-Funktion**
+
+`analyzer.py:232-238`
+
+```python
+async def analyze_antrag(text: str, bundesland: str = "NRW",
+ model: str = "qwen-plus") -> Assessment:
+ client = AsyncOpenAI(
+ api_key=settings.dashscope_api_key,
+ base_url=settings.dashscope_base_url,
+ )
+```
+
+Die Kernfunktion der Domäne instanziiert direkt einen HTTP-Client. Jeder Test
+muss den Client stubben (siehe `tests/conftest.py:33`), jeder LLM-Wechsel ist
+ein Diff in dieser Datei statt eine neue Adapter-Klasse.
+
+**B) Retry-Loop + JSON-Parsing + Pydantic in einer Kaskade**
+
+`analyzer.py:315-379` — derselbe Block vermischt Application-Logik (Retry
+mit steigender Temperatur), Infrastructure (JSON-Parse, Logging) und
+Domain-Entscheidung (Citation-Binding via `reconstruct_zitate`, Missing-
+Programme-Check via `check_missing_programmes`).
+
+**C) Anämisches Modell**
+
+`models.py:36-41`
+
+```python
+class MatrixEntry(BaseModel):
+ field: str = Field(..., pattern=r"^[A-E][1-5]$")
+ label: str
+ aspect: str
+ rating: int = Field(..., ge=-5, le=5)
+ symbol: Optional[str] = None
+```
+
+Kein Verhalten. Die Invariante „rating ≤ -4 → Gesamt-Score maximal 3/10"
+lebt im LLM-System-Prompt (`analyzer.py:168-171`), nicht in der Datenklasse.
+Das Domain-Wissen sitzt außerhalb des Modells, ist dort nicht testbar und
+nicht refaktorierbar — und kann vom LLM jederzeit unbemerkt verletzt werden,
+ohne dass der Server das merkt.
+
+**D) Repository-Pattern fehlt**
+
+`database.py` (909 LOC) wird von `main.py`, `queue.py`, `auswertungen.py`,
+`mail.py`, `monitoring.py`, `reindex_embeddings.py` direkt aufgerufen. Jeder
+neue Caller kennt Schema-Details. Bei Schema-Drift (#123 v3→v4, #94 Bookmarks,
+#124 email_subscriptions, #135 monitoring_scans) mussten alle Caller
+angepasst werden statt ein zentraler Repository-Adapter.
+
+**E) Adapter-Interface nur informell**
+
+`parlamente.py::ParlamentAdapter` (Zeile 33-70) definiert `abstract` auf
+`search()`, `get_document()`, `download_text()`. Das ist bereits ein Port —
+aber: `Drucksache` ist `@dataclass` in derselben Datei, wird von Routes
+direkt an Jinja-Templates durchgereicht und trägt Typ-Normalisierung
+(`__post_init__`) direkt im Konstruktor. Der Adapter-Contract ist deshalb
+nicht wirklich gekapselt; jede Template-Seite kennt Felder, die
+eigentlich Adapter-Detail sind.
+
+### 1.3 Ubiquitous-Language-Inkonsistenzen
+
+Deutsch-Englisch-Mix, der die Domänensprache verwässert. Vollständiges
+Glossar in Kapitel 4; hier nur die Auffälligkeiten:
+
+| Deutsch (Domain) | Englisch (Code) | Datei-Ort |
+|---|---|---|
+| Bewertung | `Assessment` | `models.py:77`, überall |
+| Fraktions-Treue | `FraktionScores` | `models.py:59` |
+| Programm-Treue | `ProgrammScore` | `models.py:51` |
+| Matrix-Feld | `MatrixEntry` (field/rating) | `models.py:36` |
+| Drucksache | `Drucksache` ✓ | `parlamente.py:15` |
+| Wahlperiode | `Wahlperiode` ✓ | `wahlperioden.py` |
+
+**Befund:** Die *zentralen* Bewertungsobjekte (Assessment, Score) sind auf
+Englisch, der Rest auf Deutsch. Kein konsistentes Muster. Die Domäne sind
+deutsche Landtage — konsequent Deutsch wäre natürlicher und konsistenter
+zur GWÖ-Literatur.
+
+---
+
+## 2. Bounded Contexts (Analyse)
+
+### 2.1 Kontext-Kandidaten (verfeinert)
+
+| Kontext | Typ | Begründung |
+|---|---|---|
+| **Antragsbewertung** | Core | Wertschöpfungs-Kern. GWÖ-Matrix, Scores, Zitate, Empfehlung — hier wird der USP erzeugt |
+| **Wahlprogramm-Wissensbasis** | Supporting | Nötig für Zitate-Verifikation, aber austauschbar (Corpus könnte auch anders sein) |
+| **Parlamentsintegration** | Supporting | Anti-Corruption-Layer zu 17 Landtags-Systemen. Pro BL austauschbar |
+| **Publikation** | Supporting | PDF, RSS, Social-Sharing — Darstellungs-Varianten einer Bewertung |
+| **Benutzer & Abonnement** | Generic | Keycloak-SSO, Bookmarks, Kommentare, Votes, E-Mail-Abos |
+| **Monitoring** | Generic | Scan-Log, Tagesstats (#135) — Ops-Ebene |
+
+### 2.2 Aggregate, Entities, Value Objects pro Kontext
+
+**Antragsbewertung**
+
+- **Aggregate-Root:** `Bewertung` (heute `Assessment` in `models.py:77`) —
+ kapselt alle Scores, Matrix, Zitate, Empfehlung für einen Antrag
+- **Entities:** keine zusätzlichen; eine Bewertung ist eine atomare Einheit
+ pro Drucksache. Version-Historie (`assessment_versions`) sind Snapshots,
+ nicht Entities
+- **Value Objects:**
+ - `GwoeScore` (0-10, float, invariant `rating ≤ -4 ⇒ score ≤ 3`)
+ - `MatrixFeld` (heute `MatrixEntry` in `models.py:36`; Code `[A-E][1-5]` + Rating -5..+5)
+ - `Empfehlung` (heute Enum, `models.py:13`) + `EmpfehlungSymbol`
+ - `Verbesserungspotenzial` (Enum, `models.py:27`)
+ - `Zitat` mit `verified: bool` (heute in `models.py:44`)
+ - `Verbesserung` als Redline-Triple `(original, vorschlag, begruendung)` (`models.py:69`)
+ - `FraktionScore` als Paar `(wahlprogramm_score, parteiprogramm_score)`
+- **Invarianten (heute implizit):**
+ - Prompt-Regel `analyzer.py:168-171` — Score-Cap bei Matrix-Feld ≤ -3
+ - Prompt-Regel `analyzer.py:101-106` — Empfehlung↔Score-Band:
+ Ablehnen 0-2, Überarbeiten 3-4, Unterstützen-mit 5-7, Uneingeschränkt 8-10
+ - Pydantic `models.py:40` — Rating-Range -5..+5
+ - Pydantic `models.py:37` — Feld-Code `[A-E][1-5]`
+ - Prompt-Regel `analyzer.py:165` — max 3 Verbesserungsvorschläge
+ - Zitat-Verify-Regel: `verified=True` nur wenn mind. 5 zusammenhängende
+ Wörter wörtlich im Chunk (heute in `embeddings.py::reconstruct_zitate`)
+
+**Parlamentsintegration**
+
+- **Aggregate-Root:** `Drucksache` (heute `@dataclass` in `parlamente.py:15`)
+- **Value Objects:**
+ - `DrucksachenNummer` (heute `str`, Format `{WP}/{Nr}(neu)?`; könnte als VO validieren)
+ - `DrucksachenTyp` + `DrucksachenTypNormiert` (heute `drucksache_typen.py`)
+ - `Bundesland` (heute Dataclass in `bundeslaender.py`)
+ - `Wahlperiode` (Nummer + Zeitraum)
+- **Domain-Service:** `TypNormalisierung` (heute `drucksache_typen.py:normalize_typ`,
+ `ist_abstimmbar`) — unbewusst schon als Domain-Service implementiert
+- **Anti-Corruption-Layer:** jede `{BL}Adapter`-Klasse in `parlamente.py`
+ ist eine. 17 Adapter sind bereits da, aber kein expliziter Protocol-Check
+ jenseits der `@abstractmethod`-Sperre
+- **Invarianten (heute implizit):**
+ - `filter_abstimmbar` muss Kleine Anfragen ausfiltern (`parlamente.py:45-60`)
+ - `typ_normiert` wird in `__post_init__` aus `typ` abgeleitet (`parlamente.py:27-30`)
+ - Drucksachen-ID muss pro `(bundesland, wahlperiode)` eindeutig sein
+
+**Wahlprogramm-Wissensbasis**
+
+- **Aggregate-Root:** `Wahlprogramm` (Partei × Wahlperiode × Bundesland)
+- **Entities:** `Chunk` (indexierter Textausschnitt, hat Seite + Text + Embedding)
+- **Value Objects:**
+ - `EmbeddingVector` (heute `bytes` in SQLite, `embedding_model`-Tag)
+ - `ChunkRef` (`{programm_id, chunk_id, seite}`)
+ - `Aehnlichkeitsscore` (cosine)
+- **Domain-Service:** `ZitatVerifier` (Zitat-Text → Chunk-Match)
+- **Invarianten:**
+ - v3- und v4-Embeddings dürfen nicht gemischt werden (ADR 0006; heute
+ `config.py::EMBEDDING_MODEL_READ`)
+ - Chunk-Grenzen stabil über Reindex (sonst invalidiert es Cross-Refs)
+
+**Publikation**
+
+- **Aggregate-Root:** `Publikation` (eine Bewertung in Zielformat-Rendering)
+- **Entities:** `PdfReport`, `FeedItem`, `SocialPost`
+- **Domain-Service:** `RedlineRenderer` (Markdown → farbig); `ShareTextGenerator`
+- **Invarianten:**
+ - RSS-Feed sortiert absteigend nach `datum`
+ - PDF rendert ALLE im Assessment vorhandenen Zitate mit Verify-Status
+
+**Benutzer & Abonnement**
+
+- **Aggregate-Roots:** `Benutzer` (Keycloak-Subject), `Abonnement` (Filter + Frequenz)
+- **Entities:** `Bookmark`, `Kommentar`, `Vote`
+- **Value Objects:** `Filter` (bundesland × partei × frequency), `UnsubscribeToken`
+- **Invarianten:**
+ - Kommentar-Sichtbarkeit: `all`/`authenticated`/`private`/`group:*`
+ (`database.py:373-384` — heute Server-seitig gefiltert)
+ - Unsubscribe-Token ist HMAC-SHA256, constant-time-compare (`mail.py:38-40`)
+
+**Monitoring**
+
+- **Aggregate-Root:** `TagesScan` (ein scan_date × bundesland)
+- **Entities:** `ScanEintrag` (eine gesehene Drucksache)
+- **Value Objects:** `ScanStatistik` (total_seen, new_count, errors)
+- **Invarianten:**
+ - `seen_first_at` ist unveränderlich, `last_seen_at` rollt vorwärts
+ (`database.py:830-841`)
+ - `UNIQUE(scan_date, bundesland)` für Summary, `UNIQUE(bundesland, drucksache)`
+ für Scans (`database.py:191, 209`)
+
+### 2.3 Context-Map mit Beziehungstypen (Mermaid)
+
+```mermaid
+graph TD
+ AB[Antragsbewertung
**Core**]
+ WP[Wahlprogramm-
Wissensbasis
Supporting]
+ PI[Parlaments-
integration
Supporting]
+ PU[Publikation
Supporting]
+ BE[Benutzer &
Abonnement
Generic]
+ MO[Monitoring
Generic]
+
+ PI -->|"Drucksache
(Customer-Supplier)"| AB
+ WP -->|"Chunks + ZitatVerify
(Customer-Supplier)"| AB
+ AB -->|"Bewertung
(Shared-Kernel)"| PU
+ AB -->|"Bewertung
(Conformist)"| BE
+ BE -.->|"Filter-Abo
(Conformist)"| PU
+ PI -.->|"Scan-Events
(Customer-Supplier)"| MO
+ AB -.->|"Kosten-Schätzung
(Published Language)"| MO
+
+ classDef core fill:#009da5,stroke:#333,color:#fff
+ classDef supporting fill:#889e33,stroke:#333,color:#fff
+ classDef generic fill:#ccc,stroke:#333
+ class AB core
+ class WP,PI,PU supporting
+ class BE,MO generic
+```
+
+**Legende der Beziehungstypen:**
+
+- **Customer-Supplier** (`PI → AB`, `WP → AB`): Antragsbewertung ist
+ Downstream-Konsument. Heute keine Contract-Tests — Sub-D-Property-Tests
+ aus ADR 0003 decken nur Zitat-Citations ab, nicht die Drucksachen-Shape.
+- **Shared-Kernel** (`AB → PU`): `Assessment` ist das Datenmodell, das
+ sowohl Core als auch Publikation direkt benutzen. Das ist heute ein
+ Schmerz: jede Pydantic-Feld-Umbenennung bricht Jinja-Templates.
+ Alternative wäre Konversion zu eigenem Publikations-DTO.
+- **Conformist** (`AB → BE`, `BE → PU`): Benutzer-Kontext liest Bewertung
+ roh, ohne eigene Übersetzung. Bei größerer Isolierung wünschenswert.
+- **Anti-Corruption-Layer:** *implizit* in jedem `{BL}Adapter`-Subclass —
+ 17-fach vorhanden, strukturell aber nicht explizit ausgewiesen.
+- **Published Language** (`AB ⇢ MO`): Monitoring konsumiert nur eine
+ stabile Teilmenge (Cost-Log); kann als publizierbares Event-Format
+ formalisiert werden.
+
+### 2.4 Anti-Corruption-Layer-Kandidaten (wo leakt Infrastructure heute)
+
+1. **`parlamente.py::Drucksache` in Jinja-Templates.** Dataclass mit
+ Adapter-Feldern wird an `templates/index.html` durchgereicht
+ (via `main.py`). Ein `AntragDto` pro Publikation wäre die saubere
+ Lösung — die Publikation würde `Drucksache` nie direkt sehen.
+2. **`Assessment`-Pydantic-Model direkt in Jinja.** Kein Rendering-DTO.
+ Felder wie `gwoe_matrix` werden im Template durchiteriert. Bei
+ Pydantic-Aliase-Änderungen brechen Templates stumm.
+3. **SQLite-Rows direkt als `dict` nach oben.** `get_assessment()`
+ (`database.py:623`) gibt `dict(row)` zurück, Routes konsumieren
+ das. Kein Mapping zum Domain-Objekt dazwischen.
+4. **Embeddings-Infrastruktur im Analyzer.** `analyzer.py:20-25`
+ importiert direkt aus `embeddings.py` (`EMBEDDINGS_DB`,
+ `reconstruct_zitate`). Der Kontext Antragsbewertung kennt ein
+ DB-File und eine Rekonstruktions-Funktion der Wissensbasis.
+5. **Keycloak-Rohformat in Kommentar-Sichtbarkeit.** `database.py:381`
+ `group:XYZ` hat TODO-Kommentar „Keycloak-Gruppen-Membership prüfen" —
+ keine Übersetzung der externen Gruppen-Kennung in eine Domain-Rolle.
+
+---
+
+## 3. Zielbild (nur als Referenz)
+
+### 3.1 Ordner-Skelett (Ports-and-Adapters)
+
+```
+webapp/app/
+├── antragsbewertung/ # Core
+│ ├── domain/
+│ │ ├── antrag.py # Entity: Antrag
+│ │ ├── bewertung.py # Aggregate-Root + VOs
+│ │ ├── gwoe_matrix.py # Matrix 2.0 als Domain-Konzept
+│ │ ├── empfehlung.py # Empfehlung / Empfehlungssymbol
+│ │ └── zitat.py # VO Zitat (Verify-Regel)
+│ ├── application/
+│ │ ├── bewerte_antrag.py # Use-Case
+│ │ └── ports.py # AntragRepository, LlmBewerter, ZitatVerifier
+│ └── infrastructure/
+│ ├── sqlite_bewertung_repo.py
+│ ├── qwen_bewerter.py
+│ └── embedding_zitat_verifier.py
+├── wahlprogramm/
+│ ├── domain/
+│ │ ├── programm.py
+│ │ ├── chunk.py
+│ │ └── chunk_registry.py
+│ ├── application/
+│ │ └── ports.py # EmbeddingStore, PdfReader
+│ └── infrastructure/
+│ ├── sqlite_chunk_store.py
+│ └── dashscope_embedder.py
+├── parlamentsintegration/
+│ ├── domain/
+│ │ ├── drucksache.py # Entity
+│ │ ├── drucksachen_nummer.py # VO
+│ │ ├── drucksachen_typ.py # VO + Normalisierung
+│ │ └── bundesland.py
+│ ├── application/
+│ │ ├── ports.py # ParlamentAdapter (Protocol)
+│ │ └── suche_antrag.py
+│ └── infrastructure/
+│ └── adapters/
+│ ├── nrw_opal.py
+│ ├── by_starweb.py
+│ └── … # 1 Datei pro BL
+├── benutzer/
+├── publikation/
+├── monitoring/
+└── interfaces/
+ ├── http/ # FastAPI + Routes pro Kontext
+ └── cli/ # Scripts (reindex, digest, scan)
+```
+
+### 3.2 Beispiel 1 — Domain-Klasse mit Verhalten: `Bewertung`
+
+```python
+# antragsbewertung/domain/bewertung.py
+from dataclasses import dataclass, field
+from typing import Iterable
+from .empfehlung import Empfehlung
+from .gwoe_matrix import MatrixFeld
+from .zitat import Zitat
+
+GWOE_MAX = 10.0
+GWOE_MIN = 0.0
+
+
+@dataclass(frozen=True)
+class Bewertung:
+ """Aggregate-Root des Kontexts Antragsbewertung.
+
+ Immutable (frozen). Jede Änderung erzeugt eine neue Bewertung
+ — passt zur vorhandenen Versions-Historie in assessment_versions.
+ """
+ drucksache: str
+ title: str
+ fraktionen: tuple[str, ...]
+ gwoe_score: float
+ matrix: tuple[MatrixFeld, ...]
+ empfehlung: Empfehlung
+ zitate_verifiziert: tuple[Zitat, ...]
+ # … weitere Felder analog models.py:77ff.
+
+ def __post_init__(self) -> None:
+ self._pruefe_invarianten()
+
+ # --- Invarianten-Prüfung -----------------------------------------
+
+ def _pruefe_invarianten(self) -> None:
+ if not (GWOE_MIN <= self.gwoe_score <= GWOE_MAX):
+ raise ValueError(f"gwoe_score außerhalb 0..10: {self.gwoe_score}")
+
+ # Invariante aus analyzer.py:169-171 (heute nur im System-Prompt):
+ if any(f.rating <= -4 for f in self.matrix) and self.gwoe_score > 3:
+ raise ValueError(
+ "Widerspruch: Matrix-Feld ≤ -4 erzwingt gwoe_score ≤ 3"
+ )
+ if any(f.rating == -3 for f in self.matrix) and self.gwoe_score > 4:
+ raise ValueError(
+ "Widerspruch: Matrix-Feld == -3 erzwingt gwoe_score ≤ 4"
+ )
+
+ # Invariante aus analyzer.py:101-106:
+ if self.empfehlung.ist_ablehnung() and self.gwoe_score > 2:
+ raise ValueError("Ablehnen nur bei gwoe_score ≤ 2")
+ if self.empfehlung.ist_uneingeschraenkt() and self.gwoe_score < 8:
+ raise ValueError("Uneingeschränkt nur bei gwoe_score ≥ 8")
+
+ # --- Verhalten ---------------------------------------------------
+
+ def ist_ablehnung(self) -> bool:
+ return self.empfehlung.ist_ablehnung()
+
+ def hat_fundamental_kritisches_feld(self) -> bool:
+ return any(f.ist_fundamental_kritisch() for f in self.matrix)
+
+ def schwerpunkt_felder(self, top_n: int = 2) -> tuple[str, ...]:
+ """Die n Felder mit höchstem absoluten Rating (Schwerpunkt-Logik
+ bisher im LLM; hier deterministisch im Domain-Modell."""
+ sortiert = sorted(self.matrix, key=lambda f: abs(f.rating), reverse=True)
+ return tuple(f.code for f in sortiert[:top_n])
+
+ def zitate_von(self, fraktion: str) -> Iterable[Zitat]:
+ return (z for z in self.zitate_verifiziert if z.fraktion == fraktion)
+```
+
+### 3.3 Beispiel 2 — Value Object mit Verhalten: `MatrixFeld`
+
+```python
+# antragsbewertung/domain/gwoe_matrix.py
+import re
+from dataclasses import dataclass
+
+FELD_PATTERN = re.compile(r"^[A-E][1-5]$")
+
+
+@dataclass(frozen=True)
+class MatrixFeld:
+ code: str # z.B. "D4"
+ label: str
+ aspect: str # Freitext warum
+ rating: int # -5..+5
+
+ def __post_init__(self) -> None:
+ if not FELD_PATTERN.fullmatch(self.code):
+ raise ValueError(f"Ungültiger Matrix-Feld-Code: {self.code!r}")
+ if not (-5 <= self.rating <= 5):
+ raise ValueError(f"Rating außerhalb -5..+5: {self.rating}")
+
+ @property
+ def symbol(self) -> str:
+ if self.rating >= 4: return "++"
+ if self.rating >= 1: return "+"
+ if self.rating == 0: return "○"
+ if self.rating >= -3: return "−"
+ return "−−"
+
+ def ist_fundamental_kritisch(self) -> bool:
+ return self.rating <= -4
+
+ def ist_kritisch(self) -> bool:
+ return self.rating <= -3
+
+ @property
+ def gruppe(self) -> str: # Zeile A-E
+ return self.code[0]
+
+ @property
+ def wert(self) -> int: # Spalte 1-5
+ return int(self.code[1])
+```
+
+### 3.4 Beispiel 3 — Ports als Protocol: `LlmBewerter`, `ZitatVerifier`
+
+```python
+# antragsbewertung/application/ports.py
+from typing import Protocol
+from ..domain.antrag import Antrag
+from ..domain.bewertung import RohBewertung, Bewertung
+from ..domain.zitat import Zitat
+
+
+class LlmBewerter(Protocol):
+ """Infrastruktur-Port für LLM-gestützte Bewertung. Austauschbar
+ zwischen Qwen, Claude, GPT, Fake-Dummy für Tests."""
+
+ async def bewerte(self, antrag: Antrag, kontext: str) -> RohBewertung:
+ """Liefert unverifizierten LLM-Output. Pydantic-valide,
+ aber Citations noch nicht gegen Chunks abgeglichen."""
+ ...
+
+
+class ZitatVerifier(Protocol):
+ """Infrastruktur-Port für Chunk-basierte Zitat-Verifikation.
+ Heute embedded-basiert, prinzipiell auch Keyword- oder Levenshtein-
+ basierte Varianten möglich."""
+
+ def verifiziere(self, zitate: list[Zitat], bundesland: str,
+ fraktionen: list[str]) -> list[Zitat]:
+ """Setzt zitat.verified = True/False und korrigiert
+ zitat.quelle/.url auf den kanonischen Chunk-Source-Label.
+ Nicht-auffindbare Zitate werden aussortiert."""
+ ...
+
+
+class AntragKontextProvider(Protocol):
+ """Liefert den System-/User-Prompt-Kontext für ein Bundesland:
+ Parlamentsname, Regierungsfraktionen, Wahlprogramme-Übersicht,
+ relevante Chunks."""
+
+ def fuer(self, bundesland: str, antragstext: str,
+ fraktionen: list[str]) -> str: ...
+```
+
+### 3.5 Beispiel 4 — Application-Service: `BewerteAntrag`
+
+```python
+# antragsbewertung/application/bewerte_antrag.py
+from dataclasses import dataclass
+from .ports import LlmBewerter, ZitatVerifier, AntragKontextProvider
+from ..domain.antrag import Antrag
+from ..domain.bewertung import Bewertung
+
+
+@dataclass
+class BewerteAntrag:
+ llm: LlmBewerter
+ verifier: ZitatVerifier
+ kontext_provider: AntragKontextProvider
+
+ async def __call__(self, antrag: Antrag) -> Bewertung:
+ kontext = self.kontext_provider.fuer(
+ antrag.bundesland, antrag.text, antrag.fraktionen,
+ )
+ roh = await self.llm.bewerte(antrag, kontext)
+ verifiziert = self.verifier.verifiziere(
+ roh.zitate, antrag.bundesland, antrag.fraktionen,
+ )
+ return Bewertung.aus_roh(roh, verifiziert)
+ # Der Constructor prüft alle Invarianten (siehe 3.2).
+```
+
+### 3.6 Repository-Pattern-Vorlage
+
+```python
+# antragsbewertung/application/ports.py
+class BewertungRepository(Protocol):
+ async def speichere(self, b: Bewertung) -> None: ...
+ async def finde(self, drucksache: str) -> Bewertung | None: ...
+ async def alle(self, bundesland: str | None = None) -> list[Bewertung]: ...
+ async def loesche(self, drucksache: str) -> bool: ...
+
+
+# antragsbewertung/infrastructure/sqlite_bewertung_repo.py
+class SqliteBewertungRepository(BewertungRepository):
+ def __init__(self, db_path: str): self._path = db_path
+
+ async def speichere(self, b: Bewertung) -> None:
+ # Mapping Bewertung → SQLite-Row: hier einmal, nirgends sonst.
+ # Die Version-History (heute database.py:540ff.) wandert ebenfalls hier rein.
+ ...
+
+ async def finde(self, drucksache: str) -> Bewertung | None:
+ row = await self._db_fetch_one(...)
+ if row is None: return None
+ return self._row_to_bewertung(row) # lokaler Mapper
+
+
+# tests: antragsbewertung/tests/infrastructure/in_memory_repo.py
+class InMemoryBewertungRepository(BewertungRepository):
+ def __init__(self): self._store: dict[str, Bewertung] = {}
+ async def speichere(self, b: Bewertung) -> None: self._store[b.drucksache] = b
+ async def finde(self, d: str) -> Bewertung | None: return self._store.get(d)
+ async def alle(self, bundesland=None):
+ return [b for b in self._store.values()
+ if bundesland in (None, "ALL") or b.bundesland == bundesland]
+ async def loesche(self, d: str) -> bool:
+ return self._store.pop(d, None) is not None
+```
+
+Das **In-Memory-Repo ist eine Fake, kein Mock** — voll funktional, keine
+erwartungs-gesteuerten Stubs, keine Magic-Methoden. In Tests identisches
+Verhalten zu SQLite, aber Reset = neue Instanz.
+
+### 3.7 Dependency-Injection — FastAPI-`Depends` vs. expliziter Container
+
+**Empfehlung: FastAPI `Depends` nutzen, keinen separaten Container einziehen.**
+
+Begründung:
+
+- Die App ist klein (6 Kontexte, ~20 Adapter-Klassen). Ein explizites
+ DI-Framework (`wired`, `punq`, `dependency-injector`) ist Overhead.
+- FastAPI-`Depends` unterstützt Scopes (Request, Session) nativ und
+ kann Sub-Dependencies cachen — das genügt.
+- Tests können `app.dependency_overrides[get_bewertung_repo] = lambda: FakeRepo()`
+ setzen und so jede Route mit Fakes fahren.
+- CLI-Scripts (`reindex_embeddings.py`, `run-digest.sh`) kriegen eine
+ explizite `bootstrap()`-Funktion in `interfaces/cli/bootstrap.py`, die
+ dieselben Konstruktoren aufruft.
+
+Skizze:
+
+```python
+# interfaces/http/dependencies.py
+from fastapi import Depends
+from ..config import settings
+
+def get_bewertung_repo() -> BewertungRepository:
+ return SqliteBewertungRepository(settings.db_path)
+
+def get_llm_bewerter() -> LlmBewerter:
+ return QwenBewerter(api_key=settings.dashscope_api_key, ...)
+
+def get_bewerte_antrag(
+ repo: BewertungRepository = Depends(get_bewertung_repo),
+ llm: LlmBewerter = Depends(get_llm_bewerter),
+ verifier: ZitatVerifier = Depends(get_zitat_verifier),
+ kontext: AntragKontextProvider = Depends(get_kontext_provider),
+) -> BewerteAntrag:
+ return BewerteAntrag(llm, verifier, kontext)
+```
+
+### 3.8 Datei-→-Kontext-Mapping
+
+| Aktuell | Kontext | Neue Lokation (Soll) |
+|---|---|---|
+| `analyzer.py` | Antragsbewertung | `antragsbewertung/application/bewerte_antrag.py` + `infrastructure/qwen_bewerter.py` |
+| `models.py::Assessment,Score,…` | Antragsbewertung | `antragsbewertung/domain/bewertung.py` |
+| `models.py::MATRIX_LABELS,COL_LABELS` | Antragsbewertung | `antragsbewertung/domain/gwoe_matrix.py` |
+| `database.py` | geteilt | pro-Kontext `infrastructure/*_repo.py` |
+| `parlamente.py::Drucksache` | Parlamentsintegration | `parlamentsintegration/domain/drucksache.py` |
+| `parlamente.py::{BL}Adapter` | Parlamentsintegration | `parlamentsintegration/infrastructure/adapters/*.py` |
+| `drucksache_typen.py` | Parlamentsintegration | `parlamentsintegration/domain/drucksachen_typ.py` |
+| `bundeslaender.py` | Parlamentsintegration | `parlamentsintegration/domain/bundesland.py` |
+| `wahlperioden.py` | Parlamentsintegration | `parlamentsintegration/domain/wahlperiode.py` |
+| `parteien.py` | Parlamentsintegration | `parlamentsintegration/domain/partei.py` |
+| `embeddings.py` | Wahlprogramm | `wahlprogramm/infrastructure/embedding_store.py` + `application/zitat_verifier.py` |
+| `wahlprogramme.py` | Wahlprogramm | `wahlprogramm/domain/programm_katalog.py` |
+| `wahlprogramm_check.py` | Wahlprogramm | `wahlprogramm/application/fehlende_programme.py` |
+| `mail.py` | Benutzer / Publikation | `benutzer/application/digest_service.py` + `publikation/infrastructure/smtp_sender.py` |
+| `auth.py` | Benutzer | `benutzer/infrastructure/keycloak_auth.py` |
+| `report.py` | Publikation | `publikation/infrastructure/pdf_renderer.py` |
+| `clustering.py` | Antragsbewertung | `antragsbewertung/application/cluster_service.py` |
+| `monitoring.py` | Monitoring | `monitoring/application/scan_service.py` + `infrastructure/monitoring_repo.py` |
+| `queue.py` | Infrastructure, geteilt | `interfaces/http/queue.py` (bleibt Prozess-lokal) |
+| `auswertungen.py` | Antragsbewertung | `antragsbewertung/application/aggregation_service.py` |
+| `validators.py` | Interface | `interfaces/http/validators.py` |
+| `main.py` | Interface | `interfaces/http/app.py` + Route-Dateien pro Kontext |
+| `config.py` | shared | `config.py` (bleibt) |
+
+---
+
+## 4. Ubiquitous-Language-Glossar (ausgebaut)
+
+Deutsch-vorrangig, Verwechslungs-Flags markiert. Quelle = wo der Begriff
+heute zuerst auftaucht.
+
+| Begriff | Definition | Kontext | Quelle | Flag |
+|---|---|---|---|---|
+| **Antrag** | Parlamentarisches Dokument, das eine Handlungsempfehlung formuliert | Parlamentsintegration | `parlamente.py` | |
+| **Drucksache** | Nummerische Eindeutig-Kennung eines Antrags innerhalb WP, Format `{WP}/{Nr}(neu)?` | Parlamentsintegration | `parlamente.py:15` | |
+| **Drucksachen-Typ** | Original-Typ vom Landtag (z.B. „Kleine Anfrage", „Gesetzentwurf") | Parlamentsintegration | `parlamente.py:24` | |
+| **Typ-normiert** | Abbildung Typ → kanonische Liste (abstimmbar j/n) | Parlamentsintegration | `drucksache_typen.py` | |
+| **Abstimmbar** | Antrag/Gesetzentwurf/Änderungsantrag — alles, worüber im Plenum entschieden wird | Parlamentsintegration | `drucksache_typen.py::ist_abstimmbar` | bewusst enger als „Drucksache" |
+| **Wahlperiode** | Legislaturperiode eines Landtags, z.B. NRW WP18 (2022-2027) | Parlamentsintegration | `wahlperioden.py` | |
+| **Bundesland** | Deutsches Bundesland als Parlaments-Herkunft | Parlamentsintegration | `bundeslaender.py` | |
+| **Parlament** | Konkrete parlamentarische Körperschaft (z.B. „Landtag NRW") | Parlamentsintegration | `bundeslaender.py::parlament_name` | |
+| **Fraktion** | Gruppe von Abgeordneten einer Partei im Parlament einer Wahlperiode | Parlamentsintegration | `bundeslaender.py::landtagsfraktionen` | |
+| **Regierungsfraktion** | Fraktion, die die aktuelle Landesregierung trägt | Parlamentsintegration | `bundeslaender.py::regierungsfraktionen` | |
+| **Oppositionsfraktion** | Nicht-Regierungsfraktion | Parlamentsintegration | (implizit) | |
+| **Landesregierung** | Regierungsbündnis aus 1-3 Fraktionen eines BL | Parlamentsintegration | (textlich) | |
+| **Antragsteller** | Fraktion(en), die den Antrag einreichen | Parlamentsintegration | `models.py:61 istAntragsteller` | |
+| **Partei** | Bundes- oder Landesverband; trägt Wahl- und Grundsatzprogramm | übergreifend | `parteien.py` | |
+| **Bewertung** | Ergebnis der GWÖ-Analyse eines Antrags mit Score, Matrix, Empfehlung | Antragsbewertung | heute `Assessment`, `models.py:77` | **Umbenennung:** Assessment → Bewertung |
+| **GWÖ-Score** | Float 0-10, aggregierter Wert der Matrix-Felder | Antragsbewertung | `models.py:84 gwoe_score` | |
+| **GWÖ-Matrix** | 5×5-Raster: Berührungsgruppen × Werte (Matrix 2.0 für Gemeinden) | Antragsbewertung | `analyzer.py:60-83` | |
+| **Matrix-Feld** | Zelle der Matrix (z.B. `D4`), bewertet mit -5 bis +5 | Antragsbewertung | heute `MatrixEntry`, `models.py:36` | **Umbenennung:** MatrixEntry → MatrixFeld |
+| **Berührungsgruppe** | Zeile A-E der Matrix (Lieferkette, Finanzen, Politik, Bürger:innen, Staat) | Antragsbewertung | `models.py:148` ROW_LABELS | |
+| **Wert (GWÖ)** | Spalte 1-5 der Matrix (Menschenwürde, Solidarität, Ökologie, Sozial, Transparenz) | Antragsbewertung | `models.py:156` COL_LABELS | |
+| **Staatsprinzip** | Rechtliches Äquivalent pro Wert-Spalte (Rechtsstaatsprinzip, Gemeinnutz, …) | Antragsbewertung | `models.py:164` | |
+| **Symbol (Matrix)** | `++ / + / ○ / − / −−` — Kurzform des Ratings | Antragsbewertung | `analyzer.py:86-92` | |
+| **Rating** | Integer -5..+5 eines Matrix-Feldes | Antragsbewertung | `models.py:40` | |
+| **Schwerpunkt** | Die 1-3 Matrix-Felder mit höchstem absoluten Rating | Antragsbewertung | `models.py:87 gwoe_schwerpunkt` | |
+| **Empfehlung** | Kategorische Handlungs-Empfehlung (4-stufig) | Antragsbewertung | `models.py:13 Empfehlung` | |
+| **Empfehlungs-Symbol** | `[X] / [!] / [+] / [++]` für UI/PDF | Antragsbewertung | `models.py:20` | |
+| **Ablehnen / Überarbeiten / Unterstützen mit / Uneingeschränkt** | Die vier Empfehlungs-Stufen | Antragsbewertung | `models.py:14-17` | |
+| **Verbesserungspotenzial** | Grad der nötigen Überarbeitung (gering/mittel/hoch/fundamental) | Antragsbewertung | `models.py:27` | |
+| **Verbesserungsvorschlag** | Konkreter Redline-Vorschlag (Original → Vorschlag → Begründung) | Antragsbewertung | `models.py:69 Verbesserung` | |
+| **Redline** | Markdown mit `**fett**` (neu) und `~~durchgestrichen~~` (raus) | Antragsbewertung / Publikation | `CLAUDE.md` „Redline-Format" | |
+| **Zitat** | Wörtliche Text-Übernahme aus einem Programm, mit Quelle+Seite | Antragsbewertung ↔ Wissensbasis | `models.py:44 Zitat` | |
+| **Zitat-Verifikation** | Prüfung: Mind. 5 zusammenhängende Wörter wörtlich in einem Chunk | Antragsbewertung | `embeddings.py::reconstruct_zitate` | |
+| **Verified-Flag** | `True`/`False`/`None` auf einem Zitat | Antragsbewertung | `models.py:48` | `None` = pre-#97 Legacy |
+| **Programm-Treue** | Oberbegriff für Wahl- und Parteiprogramm-Score einer Fraktion | Antragsbewertung | heute kein eigener Typ | **Inkonsistenz:** entweder beide Scores oder getrennt |
+| **Wahlprogramm-Score** | Float 0-10, Übereinstimmung mit Wahlprogramm | Antragsbewertung | `models.py:63 wahlprogramm` | |
+| **Parteiprogramm-Score** | Float 0-10, Übereinstimmung mit Grundsatzprogramm | Antragsbewertung | `models.py:64 parteiprogramm` | |
+| **Fraktions-Scores** | Score-Paar (Wahl- + Parteiprogramm) pro Fraktion | Antragsbewertung | heute `FraktionScores` | **Umbenennung:** FraktionScores → FraktionScoring |
+| **Antragsteller-Flag** | bool: ist diese Fraktion Einreicherin | Antragsbewertung | `models.py:61` | |
+| **Regierungs-Flag** | bool: ist diese Fraktion in der Regierung | Antragsbewertung | `models.py:62` | |
+| **Konfidenz** | LLM-Selbsteinschätzung hoch/mittel/niedrig | Antragsbewertung | `models.py:102` | Nicht identisch zu GWÖ-Score |
+| **Fehlendes Programm** | Fraktion ohne hinterlegtes Wahlprogramm (#128) | Antragsbewertung ↔ Wissensbasis | `wahlprogramm_check.py` | |
+| **Wahlprogramm** | Von einer Partei zur Wahl einer WP veröffentlichtes Programm | Wissensbasis | `wahlprogramme.py` | |
+| **Grundsatzprogramm** | Dauerhaftes Parteiprogramm (nicht wahlgebunden) | Wissensbasis | `app/kontext/parteiprogramme.md` | |
+| **Programm-Registry** | Liste aller bekannten Programme mit PDF-Pfad | Wissensbasis | `wahlprogramme.py::WAHLPROGRAMM_KONTEXT_FILES` | |
+| **Chunk** | Indexierter Textausschnitt (~500 Tokens) eines Programms | Wissensbasis | `embeddings.py` | |
+| **Chunk-Source-Label** | Kanonischer Quelle-String (z.B. „SPD NRW Wahlprogramm 2022, S. 47") | Wissensbasis | `embeddings.py::_chunk_source_label` | |
+| **Embedding** | Vektor-Repräsentation eines Chunks für semantische Suche | Wissensbasis | `embeddings.py` | |
+| **Embedding-Modell** | v3 / v4 — nicht mischbar (ADR 0006) | Wissensbasis | `config.py::EMBEDDING_MODEL_READ` | ADR 0006 |
+| **Benutzer** | Keycloak-Subject | Benutzer | `auth.py` | |
+| **Abonnement** | Filter-definierte Subscription eines Nutzers auf Bewertungen | Benutzer | `database.py::email_subscriptions` | |
+| **Bookmark** | Markierung eines Antrags durch einen Benutzer | Benutzer | `database.py::bookmarks` | |
+| **Kommentar** | User-Notiz zu einem Antrag mit Sichtbarkeit (all/authenticated/private/group:*) | Benutzer | `database.py::comments` | |
+| **Vote** | Crowd-Validation-Signal (up/down) zu Bewertung oder Teilfeld | Benutzer | `database.py::votes` | |
+| **Unsubscribe-Token** | HMAC-SHA256 von sub_id + Secret | Benutzer | `mail.py:30-40` | |
+| **Digest** | Tägliche E-Mail-Zusammenfassung für ein Abonnement | Publikation / Benutzer | `mail.py` | |
+| **Share-Post** | Social-Media-Text (Threads, Twitter, Mastodon), generiert vom LLM | Publikation | `models.py:103-105` | |
+| **PDF-Report** | WeasyPrint-gerenderter PDF einer Bewertung | Publikation | `report.py` | |
+| **RSS-/Atom-Feed** | Öffentlicher Feed über Bewertungen (#125) | Publikation | `main.py` | |
+| **Scan** | Tägliche Bestandsaufnahme aller Landtags-Quellen (#135) | Monitoring | `monitoring.py` | |
+| **Scan-Eintrag** | Einzelne gesehene Drucksache mit `seen_first_at`/`last_seen_at` | Monitoring | `database.py::monitoring_scans` | |
+| **Tages-Summary** | Pro-BL-Tag-Statistik: total_seen, new_count, errors | Monitoring | `database.py::monitoring_daily_summary` | |
+| **Adapter** | `{BL}Adapter`-Klasse, die zu einem Landtags-System übersetzt | Parlamentsintegration | `parlamente.py:33ff.` | |
+| **Monitoring-Phase** | Beobachtung ohne Auto-Bewertung (Übergangszustand neuer BL) | Monitoring | (#135) | |
+
+**Verwechslungs-Flags — die wichtigsten drei:**
+
+1. **Assessment vs. Bewertung** — dieselbe Sache. Im Code „Assessment",
+ in der Domäne „Bewertung". Migration: durchgehend Deutsch.
+2. **FraktionScores vs. FraktionScoring** — Plural klingt nach mehreren
+ Scoring-Objekten. Singular + „Scoring" klarer.
+3. **Matrix-Feld vs. Matrix-Eintrag** — heute `MatrixEntry`, Domäne sagt
+ „Feld". „Eintrag" suggeriert Liste; es ist aber eine Zelle.
+
+---
+
+## 5. Trade-off-Bewertung
+
+### 5.1 Kosten einer Voll-DDD-Migration
+
+| Aspekt | Einschätzung |
+|---|---|
+| **Nettozeit** | 4-8 Wochen Solo-Arbeit für sauberen Strangler-Fig über alle 6 Kontexte |
+| **Boilerplate** | Faktor 1.5-2× mehr Dateien (Ports, Adapter, DTOs, Mappings) |
+| **Einstiegshürde** | Ein Außenstehender muss erst die Bounded-Context-Map lesen, bevor er eine Route findet |
+| **Test-Migration** | Bestehende Unit-Suite (296 Tests) muss großteils umgeschrieben werden |
+| **Feature-Verlangsamung während Migration** | 2-3× langsamer für neue Features, die kontext-übergreifend sind |
+| **Onboarding-Zeit für neue Devs** | +1-2 Tage bis zur ersten produktiven PR |
+
+### 5.2 Nutzen
+
+| Aspekt | Einschätzung |
+|---|---|
+| **Testbarkeit** | Domain-Tests ohne DB/LLM/HTTP → 5-10× schneller, weniger flaky |
+| **Austauschbarkeit** | Qwen→Claude: 1 Adapter-Datei. SQLite→Postgres: pro Context ein Repo |
+| **Parallelisierbarkeit** | Mehrere Devs könnten pro Kontext arbeiten ohne Merge-Konflikte |
+| **Langlebigkeit** | 3-5 Jahre Projekt-Horizont ohne Big-Ball-of-Mud-Sanierung |
+| **Onboarding-Qualität** | Struktur erzählt die Domäne — neue Devs verstehen das *Was* schneller |
+| **Invarianten sichtbar** | Prompt-Regeln wandern in Python-Code und werden testbar |
+
+### 5.3 Projektcharakter-Check
+
+| Kriterium | Ausprägung | DDD-Eignung |
+|---|---|---|
+| Teamgröße | Solo (1 Dev) | DDD zahlt sich primär bei Teams aus |
+| Lebenszeit-Horizont | unklar, ggf. 3+ Jahre | DDD würde sich amortisieren |
+| Domänen-Komplexität | mittel (17 Adapter, Matrix, Zitate, Auth, Mail) | Grenzfall — komplex genug, um zu rechtfertigen, aber nicht zwingend |
+| Change-Rate | hoch (täglich neue Features) | Migration würde 2-3 Monate Feature-Entwicklung verzögern |
+| Externe API-Konsumenten | nur eigenes Frontend | keine Notwendigkeit für klare Contracts nach außen |
+| Test-Schmerz heute | mittel (conftest.py-Stubs sind wartbar) | Schmerz zu gering für großen Refactor |
+
+### 5.4 Risiko-Assessment bei *Nicht*-Migration
+
+- **1 Jahr:** vermutlich weiterhin wartbar. Die Stubbing-Strategie hält.
+- **3 Jahre:** `main.py` (aktuell 1746 LOC) wird wahrscheinlich 3000+.
+ `parlamente.py` (3397 LOC) wächst mit jeder BL-Anbindung. Adapter-Count
+ steigt (NI, internationale BL?). Zero-Coverage-Module vergrößern sich.
+ Single-File-Refactors werden schmerzhaft.
+- **Bei 3× so vielen Contexts** (z.B. Abstimmungsverhalten, Transkription,
+ ML-Klassifikation): spätestens dann Migration unumgänglich — aber dann
+ aus größerem Schmerz heraus, d.h. teurer.
+
+---
+
+## 6. Empfehlung — Drei Optionen
+
+### Option A — Voll-DDD (Strangler Fig über 6 Kontexte)
+
+- 4-8 Wochen netto
+- Alle 6 Bounded Contexts als Packages
+- Ports + Adapters durchgehend
+- Repository-Pattern + Dependency-Injection
+
+**Wann sinnvoll:** wenn das Projekt in ein Team von 2-3 übergeht, oder
+wenn absehbar große neue Kontexte (Abstimmungsverhalten, ML-Training)
+kommen.
+
+### Option B — Lightweight ★ **empfohlen**
+
+Drei gezielte DDD-Prinzipien, **kein** Package-Split:
+
+1. **Repository-Pattern für `database.py`** — einen dünnen
+ `BewertungRepository`, `AbonnementRepository`, `MonitoringRepository`
+ einziehen. 1-2 Tage Arbeit, reduziert DB-Details in Call-Sites von
+ 10+ auf 3.
+2. **Ports für Infrastructure-Abhängigkeiten** — `LlmBewerter`-Protocol
+ einführen, `AsyncOpenAI` nicht mehr in `analyzer.py` instanziieren.
+ 2-3 Tage. Test-Suite wird drastisch sauberer (kein `openai`-Stub mehr
+ in `conftest.py` nötig).
+3. **Domain-Objekte mit Verhalten** — `Bewertung.ist_ablehnung()`,
+ `MatrixFeld.ist_fundamental_kritisch()`, `Antrag.erkenne_fraktionen(ltf)`.
+ 1-2 Tage, verschiebt Domain-Wissen aus `analyzer.py` und Jinja-Templates
+ ins Modell.
+
+**Gesamt: 5-8 Tage netto, 60% des DDD-Nutzens bei 15% des Aufwands.**
+Konkrete Tages-Roadmap in Kapitel 10.
+
+### Option C — Status quo
+
+- Weiter organisch entwickeln
+- Dokumentierte Regeln in CLAUDE.md statt Strukturzwang
+- Bei Schmerz punktuell umziehen (z.B. `mail.py` bei #135-Erweiterung,
+ `clustering.py` bei nächster Clustering-Iteration)
+
+**Wann sinnvoll:** wenn die nächsten 6 Monate Projekt-Fokus nicht Code-
+Qualität, sondern Feature-Auslieferung und Datengewinnung sind (z.B. erst
+Beta-Feedback einholen, dann strukturieren).
+
+---
+
+## 7. Empfehlung mit Begründung
+
+**Option B — Lightweight**, in dieser Reihenfolge:
+
+1. **Repository-Pattern für `database.py`** (1-2 Tage) — löst ein *akutes*
+ Problem: `database.py` hat 0% Test-Coverage (#134) und wird gleichzeitig
+ von mindestens 6 Modulen direkt aufgerufen. Jede Schema-Änderung ist
+ ein Minen-Feld.
+2. **`LlmBewerter`-Port + `AsyncOpenAI` raus aus `analyzer.py`** (2-3 Tage)
+ — macht die Tests wartbar ohne den aggressiven `conftest.py`-Stub.
+3. **Domain-Objekte mit Verhalten** (1-2 Tage) — verschiebt die
+ System-Prompt-Regeln (`analyzer.py:169-171`: „Score ≤ 3 bei Feld -4")
+ in Python-Code, wo sie testbar und refaktorierbar sind.
+
+**Warum nicht A:** Das Projekt ist Solo, Change-Rate ist hoch (letzte 30
+Commits zeigen fast täglich neue Features), die Domäne ist komplex aber
+nicht unbeherrschbar. 4-8 Wochen Refactor würde den Feature-Flow
+abwürgen, und der Hauptgewinn (Team-Parallelisierbarkeit) trifft hier
+nicht zu.
+
+**Warum nicht C:** Die Test-Coverage-Zahlen aus #134 (`database.py` 0%,
+`mail.py` 0%, `main.py` sehr niedrig) zeigen, dass der Status quo auch
+Kosten hat — jeder neue Feature-Zyklus baut auf unzureichend abgesichertem
+Code-Unterbau auf. Lightweight-DDD adressiert das ohne Big-Bang.
+
+---
+
+## 8. Offen gelassen (bewusst)
+
+- **Welcher Bounded Context zuerst bei Voll-DDD?** Greenfield-Kandidat wäre
+ **Monitoring** (#135), weil der Code jung ist und wenig Rückkopplung hat.
+ Antragsbewertung-Core zuletzt.
+- **Mapping Domain↔Persistence:** Ob `Bewertung` direkt auf SQLite-Schema
+ mappt (anemic ActiveRecord) oder über expliziten Mapper — ist Folge-
+ Entscheidung, keine Blockade.
+- **Event-Sourcing:** NICHT empfohlen. Overkill für dieses Projekt.
+- **CQRS:** Nicht nötig — Read-Views existieren bereits (`/auswertungen`),
+ das ist ausreichend informell.
+
+---
+
+## 9. Test-Strategie unter DDD
+
+### 9.1 Heutige Test-Pyramide (gemessen 2026-04-20)
+
+| Schicht | Dateien | LOC | Tests | Beschreibung |
+|---|--:|--:|--:|---|
+| Unit | 16 | 3112 | 296 | Pytest über `app/*.py`, mit `conftest.py`-Stubs für `fitz`, `bs4`, `openai`, `pydantic_settings` |
+| Integration | 5 | 1331 | 27 | HTTP-Mocks, echte SQLite-Files, Adapter-Live-Calls hinter `@pytest.mark.integration` |
+| E2E | 1 | 210 | 20 | Playwright gegen laufenden Server |
+
+### 9.2 Wie verschiebt sich die Pyramide unter DDD-Lightweight (Option B)
+
+**Domain-Schicht — neue Test-Klasse.** Die Invarianten aus Kapitel 3.2
+(Bewertung, MatrixFeld) sind dann *reiner Python-Code* ohne Abhängigkeiten.
+Tests laufen ohne Stubs:
+
+```python
+def test_bewertung_verbietet_score_gt_3_bei_kritischem_feld():
+ with pytest.raises(ValueError, match="erzwingt gwoe_score ≤ 3"):
+ Bewertung(drucksache="1/1", matrix=(MatrixFeld("D4", "...", "...", -4),),
+ gwoe_score=5, empfehlung=Empfehlung.UEBERARBEITEN, ...)
+```
+
+Erwartung: +20-30 neue Unit-Tests, pro Test <10ms.
+
+**Application-Schicht — Fake-Repo statt Mock.** `BewerteAntrag`-Use-Case
+mit `InMemoryBewertungRepository` + `FakeLlmBewerter`:
+
+```python
+async def test_bewerte_antrag_speichert_version():
+ repo = InMemoryBewertungRepository()
+ llm = FakeLlmBewerter(antwort=...) # liefert konstante RohBewertung
+ uc = BewerteAntrag(llm, FakeZitatVerifier(), FakeKontextProvider())
+ b = await uc(Antrag(...))
+ assert (await repo.finde(b.drucksache)) == b
+```
+
+Erwartung: 5-10 Application-Tests, pro Test <50ms. Keine `conftest.py`-
+Stubs für `openai` mehr nötig — der Stub wird überflüssig, sobald
+`analyzer.py` nicht mehr `AsyncOpenAI` direkt instanziiert.
+
+**Infrastructure-Schicht — echte Infrastruktur.** `SqliteBewertungRepository`
+gegen temp-SQLite-File (wie heute in einigen `test_monitoring.py`-Tests
+bereits). Adapter-Tests: `httpx.MockTransport` statt Stubs in `conftest.py`.
+
+**Integration-Schicht — weitgehend unverändert.** Die 27 Integration-
+Tests laufen weiter; sie testen Ende-zu-Ende eine Bewertung und werden
+durch DI-Overrides sauberer konfigurierbar.
+
+### 9.3 Migration der bestehenden Tests
+
+| Heutiger Test | Wird zu | Bemerkung |
+|---|---|---|
+| `test_analyzer.py` (5) | Application-Test `test_bewerte_antrag.py` | Ersetzt durch Fakes |
+| `test_parlamente.py` (31) | Adapter-Tests je BL, evtl. Split | Bleiben, aber pro Adapter-Datei |
+| `test_embeddings.py` (29) | Infrastructure-Test `test_embedding_store.py` | Lose Kopplung an ZitatVerifier-Port |
+| `test_drucksache_typen.py` (48) | Domain-Test (unverändert) | Reiner VO-Test, bleibt |
+| `test_bundeslaender.py` (14) | Domain-Test (unverändert) | Bleibt |
+| `test_parteien.py` (36) | Domain-Test (unverändert) | Bleibt |
+| `test_mail.py` (30) | Split: Application (Digest-Logik) + Infrastructure (SMTP) | |
+| `test_monitoring.py` (23) | Unverändert, bereits gut geschnitten | |
+| `test_report.py` (8) | Infrastructure-Test | |
+| `test_auswertungen.py` (19) | Application-Test | |
+| `test_queue.py` (5) | Infrastructure-Test | |
+| `test_auth.py` (12) | Infrastructure-Test | |
+| `test_endpoints_smoke.py` (8) | Bleibt an der Route-Grenze | |
+
+Erwartete Netto-Änderung: **~50 Test-Funktionen umgeschrieben, +30 neu**
+in der Domain-Schicht. Gesamt-LOC der Test-Suite bleibt gleich (~5000),
+aber Stub-Zeilen in `conftest.py` (51 heute) schrumpfen auf <15.
+
+### 9.4 Integration-Test-Grenze
+
+Unverändert: **Integration-Test, wenn mindestens ein echtes Infrastructure-
+Detail (SQLite-File, HTTP-Transport, PDF-Render) beteiligt ist.** Unter
+DDD wird die Grenze nur *schärfer* — weil Application-Tests mit Fakes
+klar diesseits der Grenze sind.
+
+---
+
+## 10. Konkrete Tages-Roadmap für Option B
+
+Eine durcharbeitbare 5-8-Tage-Sequenz, jeder Tag mit Akzeptanzkriterien
+und Abhängigkeiten. Reihenfolge folgt der Schmerz-Priorität aus Kapitel 7.
+
+### Abhängigkeits-Graph
+
+```mermaid
+graph LR
+ T1[Tag 1-2
BewertungRepository]
+ T2[Tag 3
AbonnementRepo
+ MonitoringRepo]
+ T3[Tag 4
LlmBewerter-Port]
+ T4[Tag 5
ZitatVerifier-Port]
+ T5[Tag 6
Bewertung mit Verhalten]
+ T6[Tag 7
MatrixFeld mit Verhalten]
+ T7[Tag 8
Puffer + Docs]
+
+ T1 --> T2
+ T1 --> T3
+ T3 --> T4
+ T4 --> T5
+ T5 --> T6
+ T2 --> T7
+ T6 --> T7
+```
+
+### Tag 1-2 — `BewertungRepository`
+
+**Schritte:**
+
+1. `app/repositories/bewertung_repo.py` anlegen, Protocol definieren
+ (`speichere`, `finde`, `alle`, `loesche`, `versions`).
+2. `SqliteBewertungRepository` implementieren — Code aus `database.py`
+ kopieren, Mapping `dict ↔ Bewertung-Dict` hier zentralisieren.
+3. Alle Call-Sites in `main.py`, `queue.py`, `auswertungen.py`, `mail.py`,
+ `monitoring.py` auf Repo umstellen (per `Depends`).
+4. `test_bewertung_repo.py` — In-Memory-Fake + SQLite-Fixture, beide
+ gegen dasselbe Contract-Test-Set.
+
+**Akzeptanzkriterien:**
+
+- Alle direkten `aiosqlite.connect(...)`-Aufrufe zu den Assessment-
+ Tabellen stammen aus `repositories/bewertung_repo.py`.
+- `test_bewertung_repo.py` hat mindestens 8 Tests, beide Implementierungen
+ bestehen dasselbe Set.
+- `pytest` grün, bestehende Integration-Tests grün.
+
+### Tag 3 — `AbonnementRepository` + `MonitoringRepository`
+
+**Schritte:**
+
+1. Analog Tag 1-2 für `email_subscriptions` (in `app/repositories/abonnement_repo.py`).
+2. Analog für `monitoring_scans` + `monitoring_daily_summary`.
+3. `bookmarks`, `comments`, `votes` zunächst offen lassen (wenig
+ Schmerz, wenig Caller) — später als Teil von Option A.
+
+**Akzeptanzkriterien:**
+
+- `mail.py::send_daily_digests` nutzt `AbonnementRepository` statt
+ direkter DB-Calls.
+- `monitoring.py::run_daily_scan` nutzt `MonitoringRepository`.
+- `pytest` grün.
+
+### Tag 4 — `LlmBewerter`-Port + `QwenBewerter`-Adapter
+
+**Schritte:**
+
+1. `app/ports/llm_bewerter.py` — Protocol mit `bewerte(antrag, kontext) → dict`.
+2. `app/infrastructure/qwen_bewerter.py` — Kapselt `AsyncOpenAI`-Client,
+ Retry-Loop, JSON-Parse. Retries bleiben, aber *im Adapter*.
+3. `analyzer.py::analyze_antrag` wird zu `BewerteAntragService` mit
+ `LlmBewerter` als Konstruktor-Argument. Fingerprint-Logging bleibt.
+4. `conftest.py::_stub("openai")` kann entfallen, weil kein Top-Level-
+ Import mehr stattfindet.
+5. `tests/test_analyzer.py` umschreiben auf `FakeLlmBewerter`.
+
+**Akzeptanzkriterien:**
+
+- Keine `AsyncOpenAI`-Instanziierung mehr im `antragsbewertung`-Pfad
+ außerhalb von `QwenBewerter`.
+- `conftest.py` ist mindestens 10 Zeilen kürzer.
+- Adapter-Wechsel (z.B. `ClaudeBewerter`) ist eine neue Datei ohne
+ Änderungen in `bewerte_antrag.py`.
+
+### Tag 5 — `ZitatVerifier`-Port
+
+**Schritte:**
+
+1. `app/ports/zitat_verifier.py` — Protocol.
+2. `app/infrastructure/embedding_zitat_verifier.py` — extrahiert aus
+ `embeddings.py::reconstruct_zitate` + Chunk-Lookup.
+3. `BewerteAntragService` nimmt `ZitatVerifier` im Konstruktor.
+
+**Akzeptanzkriterien:**
+
+- `embeddings.py` hat keine Kenntnis mehr über Assessment-Shape; der
+ Verifier arbeitet mit einer Zitat-Liste und gibt die verifizierte
+ Liste zurück.
+- Vorhandene Sub-D-Citation-Property-Tests (ADR 0003) laufen weiter
+ grün, aber gegen die Port-Schnittstelle.
+
+### Tag 6 — Domain-Objekte mit Verhalten I: `Bewertung`
+
+**Schritte:**
+
+1. `app/domain/bewertung.py` — Dataclass frozen, Invarianten aus
+ Kapitel 3.2, Verhaltens-Methoden (`ist_ablehnung`,
+ `hat_fundamental_kritisches_feld`, `schwerpunkt_felder`).
+2. `models.py::Assessment` bleibt vorerst als DTO an der Schicht-Grenze
+ HTTP/Persistence; der Application-Service arbeitet intern mit
+ `Bewertung`.
+3. Test-Suite `tests/test_domain_bewertung.py` mit 15-20 Invarianten-
+ Tests.
+
+**Akzeptanzkriterien:**
+
+- Jede Invariante aus dem System-Prompt (`analyzer.py:168-171` + `:101-106`)
+ ist durch einen Python-Test abgedeckt.
+- Doppelte Widersprüche (Feld -4 aber Score 5) werfen in `Bewertung.__post_init__`.
+- `pytest` grün.
+
+### Tag 7 — Domain-Objekte mit Verhalten II: `MatrixFeld`, `Empfehlung`
+
+**Schritte:**
+
+1. `app/domain/gwoe_matrix.py` — `MatrixFeld` als frozen VO (Kap. 3.3).
+2. `app/domain/empfehlung.py` — Enum mit Verhaltens-Methoden
+ (`ist_ablehnung`, `ist_uneingeschraenkt`, `empfohlene_score_range`).
+3. Templates (`templates/*.html`) weiter mit Assessment-DTO — Jinja
+ bleibt unverändert auf Feld-Zugriff-Ebene.
+
+**Akzeptanzkriterien:**
+
+- `MatrixFeld.symbol` ersetzt die Symbol-Tabelle aus `analyzer.py:86-92`.
+- `Empfehlung.empfohlene_score_range()` gibt ein `(min, max)`-Tupel
+ zurück und wird von `Bewertung._pruefe_invarianten` genutzt.
+
+### Tag 8 — Puffer, Docs, ADR
+
+**Schritte:**
+
+1. ADR 0008 „DDD-Lightweight" in `docs/adr/` anlegen.
+2. `CLAUDE.md` um Abschnitt „Neue Regeln nach DDD-Lightweight" ergänzen
+ (DB nur über Repo, LLM nur über Port, Invarianten nur in Domain).
+3. Offene Stellen sammeln (Kommentar-Repo, Vote-Repo, Bookmark-Repo).
+4. Release-Tag: `ddd-lightweight-done`.
+
+**Akzeptanzkriterien:**
+
+- ADR 0008 merged.
+- CLAUDE.md hat neue Regeln.
+- Issue #136 wird geschlossen mit Link auf ADR und Bewertungs-Dokument.
+
+### 10.1 Risiko-Matrix pro Schritt
+
+| Schritt | Risiko | Wahrscheinlichkeit | Impact | Mitigation |
+|---|---|:-:|:-:|---|
+| Tag 1-2 Repo-Einzug | Versions-Historie-Bug (alte Snapshot-Logik) | mittel | hoch | Contract-Test `speichere_zweimal_erzeugt_version` |
+| Tag 1-2 Repo-Einzug | Async-Connection-Leak | niedrig | mittel | `async with` zentral im Repo, nicht delegiert |
+| Tag 3 MonitoringRepo | UNIQUE-Constraint-Regression | niedrig | hoch | Bestehende `test_monitoring.py`-Tests |
+| Tag 4 LlmBewerter-Port | Retry-Semantik ändert sich (Temperature-Schritte) | mittel | mittel | Test `test_qwen_bewerter_erhoeht_temperatur_auf_retry` |
+| Tag 4 LlmBewerter-Port | JSON-Parse-Forensik-Log bricht (Fingerprint) | niedrig | niedrig | Fingerprint explizit getestet |
+| Tag 5 ZitatVerifier-Port | Citation-Binding (ADR 0003) verliert Genauigkeit | mittel | hoch | ADR-0003-Property-Tests müssen alle grün bleiben |
+| Tag 6 Bewertung-Domain | Bestehende Bewertungen in DB sind inkonsistent mit neuen Invarianten | hoch | mittel | Migrations-Script `validate_existing_assessments.py` vor Roll-out |
+| Tag 7 MatrixFeld | Symbol-Rendering bricht (emoji vs. `++`) | niedrig | niedrig | Template-Smoke-Test |
+| Jeder Schritt | Merge-Konflikte bei parallelen Feature-PRs | niedrig (Solo) | niedrig | Jeder Schritt ist eigener PR, max. 2 Tage offen |
+
+### 10.2 Ausstiegs-Trigger — wann abbrechen und zurückrollen
+
+Ehrliche Grenzen, damit sich eine gestartete Migration nicht totlaufen
+kann:
+
+- **Nach Tag 2 (BewertungRepository):** wenn ≥ 3 Test-Regressionen in
+ der bestehenden Suite NICHT innerhalb von 4 Stunden reproduzierbar/
+ behebbar sind → zurückrollen und Plan neu denken.
+- **Nach Tag 4 (LlmBewerter-Port):** wenn die Integration-Tests
+ (`test_citations_substring.py`) einen messbaren Genauigkeits-Drop
+ zeigen (z.B. `verified`-Rate fällt um > 5 Prozentpunkte) → Port-Design
+ war falsch, zurückrollen.
+- **Nach Tag 6 (Bewertung-Domain):** wenn > 10% der bestehenden
+ Assessments in der Produktiv-DB gegen die neuen Invarianten verstoßen
+ → Invarianten waren falsch eingegrenzt, ODER Altbestand ist
+ korrumpiert. Zurückrollen, Altbestand-Audit separat machen.
+- **Übergreifend:** wenn das Total-Diff nach Tag 5 > 4000 LOC ist → die
+ Migration ist zu groß geworden, einen Teil der Pläne abspalten.
+- **Zeit-Cap:** wenn Tag 6 am kalendarischen Tag 10 noch nicht begonnen
+ wurde, ist das Vorhaben gescheitert (Scope-Creep oder Priorisierungs-
+ Problem). Entscheidung: erst fertig machen, was bis dahin offen ist,
+ Rest cancelln.
+
+---
+
+## 11. Was DDD NICHT löst (ehrlich)
+
+DDD ist kein Allheilmittel. Die folgenden Probleme bleiben nach Option B
+(und auch nach Option A) bestehen:
+
+### 11.1 LLM-Halluzinationen
+
+Zitate erfinden, Matrix-Felder falsch zuordnen, Score-Band verletzen —
+das sind **Daten-Qualitäts-Probleme**. DDD hilft nur indirekt:
+
+- Invarianten-Checks in `Bewertung.__post_init__` fangen *offensichtliche*
+ Score-Band-Verstöße (Ablehnen bei Score 9) ab und werfen.
+- Zitat-Verify bleibt ein statistisches Matching gegen Chunks —
+ hart gelöst nur, wenn der Verifier 100% der Chunks kennt (was er tut),
+ aber das LLM kann trotzdem Pseudo-Zitate produzieren, die der Verifier
+ mit `verified=False` markiert, aber nicht entfernt.
+- Die Retry-Schleife mit steigender Temperatur (`analyzer.py:326`) ist
+ eine Heuristik, keine Garantie.
+
+**Was hilft tatsächlich:** besseres Prompt-Engineering, Few-Shot-
+Beispiele, eventuell eine Validierungs-Zweit-LLM.
+
+### 11.2 Adapter-Schema-Drift
+
+Die 17 `{BL}Adapter` übersetzen jeweils aus einer externen Parlaments-
+Website. Die Website kann sich jederzeit ändern. DDD mit Anti-Corruption-
+Layer bringt eine klarere Trennung, aber:
+
+- **Drift wird nur entdeckt, wenn aktiv überwacht.** Die Monitoring-
+ Tabelle (`monitoring_daily_summary`, #135) ist der richtige Ort,
+ aber sie zählt Treffer, nicht *Schema-Abweichungen*. Der Fehler-Fall
+ „Adapter liefert plötzlich leere `fraktionen`" erfordert zusätzliche
+ Schema-Assertions.
+- DDD verhindert nicht, dass ein Adapter stillschweigend vom Contract
+ abweicht (TH leerer Link, BB Datum-Bug aus #61).
+
+**Was hilft tatsächlich:** Contract-Tests pro Adapter + Schema-Assertions
+im Scan-Job, die Adapter-Output sofort validieren.
+
+### 11.3 Performance und Latenz
+
+DDD hat **keine Aussage** zu:
+
+- LLM-Antwortzeiten (dominieren End-to-End-Latenz mit 15-60s pro Bewertung).
+- Embedding-Search-Performance (SQLite-Full-Scan bei Cosine).
+- Queue-Durchsatz (heute 3 parallele Worker, max. 50 Queue-Size).
+
+Wer Durchsatz verdoppeln will, muss Queue-/DB-/LLM-API-Details anfassen —
+die DDD-Struktur macht das *nicht leichter oder schwerer*.
+
+### 11.4 Developer-Erfahrung
+
+- Neue Devs brauchen nach DDD-Lightweight ca. **1 Tag mehr bis zur ersten
+ produktiven PR**, weil sie zuerst das Ports-und-Adapter-Schema verstehen
+ müssen. Nach Option A eher 2-3 Tage.
+- Das ist für Solo-Arbeit fast irrelevant, für Team-Onboarding aber ein
+ echter Kostenfaktor, der erst ab 3+ Devs durch Parallelisierung
+ überkompensiert wird.
+
+### 11.5 Migrations-Risiko der Daten
+
+Die existierenden ~N Assessments in der Produktiv-DB sind vor DDD-
+Invarianten entstanden. Mit großer Wahrscheinlichkeit gibt es
+historische Bewertungen, die das strikte Score-Band (Kap. 3.2) verletzen
+würden. Entweder:
+
+- **Nachsichtiger Constructor:** `Bewertung.aus_altbestand()`-Factory,
+ die Invarianten nur warnt, nicht wirft. Risiko: Bug-verstecken.
+- **Einmalige Daten-Korrektur:** Script, das alle Altfälle mit dem
+ aktuellen LLM neu bewertet (Kosten). Sauber, aber teuer.
+- **Validate-Only-Modus:** neue Invarianten gelten nur für *neue*
+ Bewertungen. Schema-Flag in der Row. Pragmatisch.
+
+Ich empfehle den Validate-Only-Modus während der Migration und einen
+separaten Altbestand-Audit als eigenes Issue.
+
+---
+
+## Appendix: Weiterführendes
+
+- **Buch:** Eric Evans, *Domain-Driven Design* (2003) — Kapitel 2 „Ubiquitous
+ Language" und 14 „Maintaining Model Integrity" sind hier einschlägig.
+- **Blog:** Vaughn Vernon, *Effective Aggregate Design* (pdf, 3 Teile)
+- **Python-spezifisch:** Harry Percival + Bob Gregory, *Architecture
+ Patterns with Python* (O'Reilly) — zeigt Hexagonal + DDD + CQRS in genau
+ dem Flask/FastAPI-Kontext dieser App.
+- **ADRs dieses Projekts:** 0002 (Adapter-Architektur, informelles Port),
+ 0003 (Citation-Property-Tests), 0006 (Embedding-Modell-Migration).
diff --git a/docs/analysen/protokoll-parser-v6-machbarkeit.md b/docs/analysen/protokoll-parser-v6-machbarkeit.md
new file mode 100644
index 0000000..2847f62
--- /dev/null
+++ b/docs/analysen/protokoll-parser-v6-machbarkeit.md
@@ -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_.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-
Samples
beschaffbar?}
+ Samples -->|nein| Stop1[STOP:
Sample-Beschaffung zuerst]
+ Samples -->|ja, ≥3 BL| Scope{Scope
klar?}
+ Scope -->|nur NRW, kurzfristig| OptA[Option A
v5 härten auf NRW]
+ Scope -->|alle 17 BL| OptFamily{Kosten-Bereitschaft
für LLM-Calls?}
+ OptFamily -->|nein, offline nötig| OptA_scaled[Option A:
Rule-Engine pro BL
34-51 Tage]
+ OptFamily -->|ja, 1500 €/WP OK| OptC[Option C Hybrid:
5-7 Tage Grund
+ 0,5 Tag/BL]
+ OptC --> POC[POC auf 2 BL:
NRW + HE
Ground-Truth-Set je 3 Prot.]
+ POC --> Eval{Recall
≥ 80 % auf HE?}
+ Eval -->|ja| Rollout[Rollout auf 17 BL]
+ Eval -->|nein| Rethink[LLM-Prompt iterieren
oder auf B eskalieren]
+ OptA_scaled --> NRW_first[NRW zuerst,
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//` (git-lfs
+oder außerhalb des Repos, je nach Dateigröße). Pro Protokoll eine
+`.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.
diff --git a/docs/reference/zugriffsrechte.md b/docs/reference/zugriffsrechte.md
new file mode 100644
index 0000000..07eb61c
--- /dev/null
+++ b/docs/reference/zugriffsrechte.md
@@ -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.
diff --git a/pytest.ini b/pytest.ini
index 5ea7277..9a78c02 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -10,4 +10,5 @@ filterwarnings =
markers =
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"
-addopts = -m "not integration"
+ e2e: Playwright browser tests against live site, requires chromium
+addopts = -m "not integration and not e2e"
diff --git a/scripts/deploy.sh b/scripts/deploy.sh
new file mode 100755
index 0000000..ad012bb
--- /dev/null
+++ b/scripts/deploy.sh
@@ -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 ==="
diff --git a/scripts/run-digest.sh b/scripts/run-digest.sh
new file mode 100755
index 0000000..379ee59
--- /dev/null
+++ b/scripts/run-digest.sh
@@ -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"
diff --git a/scripts/run-monitoring-scan.sh b/scripts/run-monitoring-scan.sh
new file mode 100755
index 0000000..4a20a34
--- /dev/null
+++ b/scripts/run-monitoring-scan.sh
@@ -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"
diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh
new file mode 100755
index 0000000..79f6f0d
--- /dev/null
+++ b/scripts/smoke-test.sh
@@ -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" '