ADRs: - 0006 Embedding-Modell-Migration v3->v4 (#123) - 0008 DDD-Lightweight-Migration (#136) Analysen: - ddd-bewertung.md (1237 Zeilen) — vollstaendige DDD-Analyse mit Tages-Roadmap - protokoll-parser-v6-machbarkeit.md (418 Zeilen) — #106 Phase 2 Vorbereitung Reference: - zugriffsrechte.md — 63 Routes x 3 User-Status, UI-Sichtbarkeits-Matrix Ops: - scripts/deploy.sh — mit Uptime-Kuma-Wartungsmodus (#149) - scripts/run-digest.sh — taeglicher Mail-Digest-Cron - scripts/run-monitoring-scan.sh — Monitoring-Scan-Cron (noch nicht aktiv) - scripts/smoke-test.sh — Gesamt-Funktionspruefung - pytest.ini: integration/slow/e2e Markers, addopts not-integration Tests/integration/: Live-Adapter-Tests + Frontend-XRef + Citation-Substring + Wahlprogramm-Indexed (4 Live-Test-Suites, marker-opt-in) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
57 KiB
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
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
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(heuteAssessmentinmodels.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, invariantrating ≤ -4 ⇒ score ≤ 3)MatrixFeld(heuteMatrixEntryinmodels.py:36; Code[A-E][1-5]+ Rating -5..+5)Empfehlung(heute Enum,models.py:13) +EmpfehlungSymbolVerbesserungspotenzial(Enum,models.py:27)Zitatmitverified: bool(heute inmodels.py:44)Verbesserungals Redline-Triple(original, vorschlag, begruendung)(models.py:69)FraktionScoreals 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=Truenur wenn mind. 5 zusammenhängende Wörter wörtlich im Chunk (heute inembeddings.py::reconstruct_zitate)
- Prompt-Regel
Parlamentsintegration
- Aggregate-Root:
Drucksache(heute@dataclassinparlamente.py:15) - Value Objects:
DrucksachenNummer(heutestr, Format{WP}/{Nr}(neu)?; könnte als VO validieren)DrucksachenTyp+DrucksachenTypNormiert(heutedrucksache_typen.py)Bundesland(heute Dataclass inbundeslaender.py)Wahlperiode(Nummer + Zeitraum)
- Domain-Service:
TypNormalisierung(heutedrucksache_typen.py:normalize_typ,ist_abstimmbar) — unbewusst schon als Domain-Service implementiert - Anti-Corruption-Layer: jede
{BL}Adapter-Klasse inparlamente.pyist eine. 17 Adapter sind bereits da, aber kein expliziter Protocol-Check jenseits der@abstractmethod-Sperre - Invarianten (heute implizit):
filter_abstimmbarmuss Kleine Anfragen ausfiltern (parlamente.py:45-60)typ_normiertwird in__post_init__austypabgeleitet (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(heutebytesin 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)
- v3- und v4-Embeddings dürfen nicht gemischt werden (ADR 0006; heute
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
- RSS-Feed sortiert absteigend nach
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)
- Kommentar-Sichtbarkeit:
Monitoring
- Aggregate-Root:
TagesScan(ein scan_date × bundesland) - Entities:
ScanEintrag(eine gesehene Drucksache) - Value Objects:
ScanStatistik(total_seen, new_count, errors) - Invarianten:
seen_first_atist unveränderlich,last_seen_atrollt 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)
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):Assessmentist 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)
parlamente.py::Drucksachein Jinja-Templates. Dataclass mit Adapter-Feldern wird antemplates/index.htmldurchgereicht (viamain.py). EinAntragDtopro Publikation wäre die saubere Lösung — die Publikation würdeDrucksachenie direkt sehen.Assessment-Pydantic-Model direkt in Jinja. Kein Rendering-DTO. Felder wiegwoe_matrixwerden im Template durchiteriert. Bei Pydantic-Aliase-Änderungen brechen Templates stumm.- SQLite-Rows direkt als
dictnach oben.get_assessment()(database.py:623) gibtdict(row)zurück, Routes konsumieren das. Kein Mapping zum Domain-Objekt dazwischen. - Embeddings-Infrastruktur im Analyzer.
analyzer.py:20-25importiert direkt ausembeddings.py(EMBEDDINGS_DB,reconstruct_zitate). Der Kontext Antragsbewertung kennt ein DB-File und eine Rekonstruktions-Funktion der Wissensbasis. - Keycloak-Rohformat in Kommentar-Sichtbarkeit.
database.py:381group:XYZhat 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
# 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
# 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
# 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
# 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
# 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-
Dependsunterstü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 explizitebootstrap()-Funktion ininterfaces/cli/bootstrap.py, die dieselben Konstruktoren aufruft.
Skizze:
# 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:
- Assessment vs. Bewertung — dieselbe Sache. Im Code „Assessment", in der Domäne „Bewertung". Migration: durchgehend Deutsch.
- FraktionScores vs. FraktionScoring — Plural klingt nach mehreren Scoring-Objekten. Singular + „Scoring" klarer.
- 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:
- Repository-Pattern für
database.py— einen dünnenBewertungRepository,AbonnementRepository,MonitoringRepositoryeinziehen. 1-2 Tage Arbeit, reduziert DB-Details in Call-Sites von 10+ auf 3. - Ports für Infrastructure-Abhängigkeiten —
LlmBewerter-Protocol einführen,AsyncOpenAInicht mehr inanalyzer.pyinstanziieren. 2-3 Tage. Test-Suite wird drastisch sauberer (keinopenai-Stub mehr inconftest.pynötig). - Domain-Objekte mit Verhalten —
Bewertung.ist_ablehnung(),MatrixFeld.ist_fundamental_kritisch(),Antrag.erkenne_fraktionen(ltf). 1-2 Tage, verschiebt Domain-Wissen ausanalyzer.pyund 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.pybei #135-Erweiterung,clustering.pybei 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:
- Repository-Pattern für
database.py(1-2 Tage) — löst ein akutes Problem:database.pyhat 0% Test-Coverage (#134) und wird gleichzeitig von mindestens 6 Modulen direkt aufgerufen. Jede Schema-Änderung ist ein Minen-Feld. LlmBewerter-Port +AsyncOpenAIraus ausanalyzer.py(2-3 Tage) — macht die Tests wartbar ohne den aggressivenconftest.py-Stub.- 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
Bewertungdirekt 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:
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:
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
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:
app/repositories/bewertung_repo.pyanlegen, Protocol definieren (speichere,finde,alle,loesche,versions).SqliteBewertungRepositoryimplementieren — Code ausdatabase.pykopieren, Mappingdict ↔ Bewertung-Dicthier zentralisieren.- Alle Call-Sites in
main.py,queue.py,auswertungen.py,mail.py,monitoring.pyauf Repo umstellen (perDepends). 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 ausrepositories/bewertung_repo.py. test_bewertung_repo.pyhat mindestens 8 Tests, beide Implementierungen bestehen dasselbe Set.pytestgrün, bestehende Integration-Tests grün.
Tag 3 — AbonnementRepository + MonitoringRepository
Schritte:
- Analog Tag 1-2 für
email_subscriptions(inapp/repositories/abonnement_repo.py). - Analog für
monitoring_scans+monitoring_daily_summary. bookmarks,comments,voteszunächst offen lassen (wenig Schmerz, wenig Caller) — später als Teil von Option A.
Akzeptanzkriterien:
mail.py::send_daily_digestsnutztAbonnementRepositorystatt direkter DB-Calls.monitoring.py::run_daily_scannutztMonitoringRepository.pytestgrün.
Tag 4 — LlmBewerter-Port + QwenBewerter-Adapter
Schritte:
app/ports/llm_bewerter.py— Protocol mitbewerte(antrag, kontext) → dict.app/infrastructure/qwen_bewerter.py— KapseltAsyncOpenAI-Client, Retry-Loop, JSON-Parse. Retries bleiben, aber im Adapter.analyzer.py::analyze_antragwird zuBewerteAntragServicemitLlmBewerterals Konstruktor-Argument. Fingerprint-Logging bleibt.conftest.py::_stub("openai")kann entfallen, weil kein Top-Level- Import mehr stattfindet.tests/test_analyzer.pyumschreiben aufFakeLlmBewerter.
Akzeptanzkriterien:
- Keine
AsyncOpenAI-Instanziierung mehr imantragsbewertung-Pfad außerhalb vonQwenBewerter. conftest.pyist mindestens 10 Zeilen kürzer.- Adapter-Wechsel (z.B.
ClaudeBewerter) ist eine neue Datei ohne Änderungen inbewerte_antrag.py.
Tag 5 — ZitatVerifier-Port
Schritte:
app/ports/zitat_verifier.py— Protocol.app/infrastructure/embedding_zitat_verifier.py— extrahiert ausembeddings.py::reconstruct_zitate+ Chunk-Lookup.BewerteAntragServicenimmtZitatVerifierim Konstruktor.
Akzeptanzkriterien:
embeddings.pyhat 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:
app/domain/bewertung.py— Dataclass frozen, Invarianten aus Kapitel 3.2, Verhaltens-Methoden (ist_ablehnung,hat_fundamental_kritisches_feld,schwerpunkt_felder).models.py::Assessmentbleibt vorerst als DTO an der Schicht-Grenze HTTP/Persistence; der Application-Service arbeitet intern mitBewertung.- Test-Suite
tests/test_domain_bewertung.pymit 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__. pytestgrün.
Tag 7 — Domain-Objekte mit Verhalten II: MatrixFeld, Empfehlung
Schritte:
app/domain/gwoe_matrix.py—MatrixFeldals frozen VO (Kap. 3.3).app/domain/empfehlung.py— Enum mit Verhaltens-Methoden (ist_ablehnung,ist_uneingeschraenkt,empfohlene_score_range).- Templates (
templates/*.html) weiter mit Assessment-DTO — Jinja bleibt unverändert auf Feld-Zugriff-Ebene.
Akzeptanzkriterien:
MatrixFeld.symbolersetzt die Symbol-Tabelle ausanalyzer.py:86-92.Empfehlung.empfohlene_score_range()gibt ein(min, max)-Tupel zurück und wird vonBewertung._pruefe_invariantengenutzt.
Tag 8 — Puffer, Docs, ADR
Schritte:
- ADR 0008 „DDD-Lightweight" in
docs/adr/anlegen. CLAUDE.mdum Abschnitt „Neue Regeln nach DDD-Lightweight" ergänzen (DB nur über Repo, LLM nur über Port, Invarianten nur in Domain).- Offene Stellen sammeln (Kommentar-Repo, Vote-Repo, Bookmark-Repo).
- 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=Falsemarkiert, 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 leerefraktionen" 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).