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