gwoe-antragspruefer/docs/analysen/ddd-bewertung.md

1238 lines
57 KiB
Markdown
Raw Normal View History

# 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).