gwoe-antragspruefer/docs/analysen/ddd-bewertung.md
Dotty Dotter 2dec009b5c docs+ops: ADRs 0006/0008, DDD-Bewertung, Zugriffsrechte, Smoke-Test, Cron-Scripts
ADRs:
- 0006 Embedding-Modell-Migration v3->v4 (#123)
- 0008 DDD-Lightweight-Migration (#136)

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00

1238 lines
57 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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<br/>**Core**]
WP[Wahlprogramm-<br/>Wissensbasis<br/>Supporting]
PI[Parlaments-<br/>integration<br/>Supporting]
PU[Publikation<br/>Supporting]
BE[Benutzer &<br/>Abonnement<br/>Generic]
MO[Monitoring<br/>Generic]
PI -->|"Drucksache<br/>(Customer-Supplier)"| AB
WP -->|"Chunks + ZitatVerify<br/>(Customer-Supplier)"| AB
AB -->|"Bewertung<br/>(Shared-Kernel)"| PU
AB -->|"Bewertung<br/>(Conformist)"| BE
BE -.->|"Filter-Abo<br/>(Conformist)"| PU
PI -.->|"Scan-Events<br/>(Customer-Supplier)"| MO
AB -.->|"Kosten-Schätzung<br/>(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<br/>BewertungRepository]
T2[Tag 3<br/>AbonnementRepo<br/>+ MonitoringRepo]
T3[Tag 4<br/>LlmBewerter-Port]
T4[Tag 5<br/>ZitatVerifier-Port]
T5[Tag 6<br/>Bewertung mit Verhalten]
T6[Tag 7<br/>MatrixFeld mit Verhalten]
T7[Tag 8<br/>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).