From 2dec009b5ca3d55d7307a8956e5aa6e21be01438 Mon Sep 17 00:00:00 2001 From: Dotty Dotter Date: Sat, 25 Apr 2026 20:55:57 +0200 Subject: [PATCH] docs+ops: ADRs 0006/0008, DDD-Bewertung, Zugriffsrechte, Smoke-Test, Cron-Scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADRs: - 0006 Embedding-Modell-Migration v3->v4 (#123) - 0008 DDD-Lightweight-Migration (#136) Analysen: - ddd-bewertung.md (1237 Zeilen) — vollstaendige DDD-Analyse mit Tages-Roadmap - protokoll-parser-v6-machbarkeit.md (418 Zeilen) — #106 Phase 2 Vorbereitung Reference: - zugriffsrechte.md — 63 Routes x 3 User-Status, UI-Sichtbarkeits-Matrix Ops: - scripts/deploy.sh — mit Uptime-Kuma-Wartungsmodus (#149) - scripts/run-digest.sh — taeglicher Mail-Digest-Cron - scripts/run-monitoring-scan.sh — Monitoring-Scan-Cron (noch nicht aktiv) - scripts/smoke-test.sh — Gesamt-Funktionspruefung - pytest.ini: integration/slow/e2e Markers, addopts not-integration Tests/integration/: Live-Adapter-Tests + Frontend-XRef + Citation-Substring + Wahlprogramm-Indexed (4 Live-Test-Suites, marker-opt-in) Co-Authored-By: Claude Opus 4.7 (1M context) --- ...0006-embedding-model-migration-v3-to-v4.md | 104 ++ docs/adr/index.md | 3 + docs/analysen/ddd-bewertung.md | 1237 +++++++++++++++++ .../protokoll-parser-v6-machbarkeit.md | 418 ++++++ docs/reference/zugriffsrechte.md | 140 ++ pytest.ini | 3 +- scripts/deploy.sh | 85 ++ scripts/run-digest.sh | 19 + scripts/run-monitoring-scan.sh | 27 + scripts/smoke-test.sh | 112 ++ 10 files changed, 2147 insertions(+), 1 deletion(-) create mode 100644 docs/adr/0006-embedding-model-migration-v3-to-v4.md create mode 100644 docs/analysen/ddd-bewertung.md create mode 100644 docs/analysen/protokoll-parser-v6-machbarkeit.md create mode 100644 docs/reference/zugriffsrechte.md create mode 100755 scripts/deploy.sh create mode 100755 scripts/run-digest.sh create mode 100755 scripts/run-monitoring-scan.sh create mode 100755 scripts/smoke-test.sh 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" '