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

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

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

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

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

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

57 KiB
Raw Blame History

Domain-Driven Design — Bewertung für den GWÖ-Antragsprüfer

Autor: Claude Code (autonom) Datum: 2026-04-20 Issue: #136 Status: Analyse — keine Umsetzung


TL;DR

Das Repository ist eine Technical-Layering-Architektur (alle Models in einer Datei, alle DB-Zugriffe in einer Datei, alle Adapter in einer Datei …), die für ein Solo-Projekt dieser Größe pragmatisch angemessen ist. Voll-DDD würde 4-8 Wochen netto kosten und den Entwicklungsfluss verlangsamen, ohne dass die Domäne ausreichend komplex wäre, um das zu rechtfertigen.

Empfehlung: Option B (Lightweight) — drei gezielte DDD-Prinzipien ohne Package-Split, 5-8 Tage netto. Details in Kapitel 7 und die konkrete Tages-Roadmap in Kapitel 10.


1. Ist-Zustand

1.1 Modul-Inventar

23 Dateien in webapp/app/, flach nebeneinander, technisch geschichtet nach Funktion. LOC gemessen mit wc -l am 2026-04-20:

Datei LOC Rolle DDD-Sicht
main.py 1746 FastAPI-App + Routes + Middleware Interface + Application + Infrastructure (alles)
parlamente.py 3397 Adapter für 17 Landtage Domain + Infrastructure + Anti-Corruption-Layer
embeddings.py 1068 Wahlprogramm-Chunks + Suche Infrastructure
database.py 909 SQLite-Persistenz Infrastructure, von überall aufgerufen
report.py 647 PDF-Rendering Infrastructure
bundeslaender.py 480 BL-Stammdaten Domain-Katalog
wahlprogramme.py 447 Registry der PDFs Domain-Katalog + Infrastructure
analyzer.py 379 LLM-Bewertung Domain + Infrastructure vermischt
parteien.py 334 Partei-Stammdaten Domain-Katalog
monitoring.py 322 Scan + Daily-Stats (#135) Application + Infrastructure
clustering.py 312 Embedding-Nähe-Graph Domain-Algorithm + Infrastructure
queue.py 303 Background-Worker Infrastructure
auth.py 300 Keycloak-OIDC Infrastructure
reindex_embeddings.py 234 CLI-Script Application / CLI
auswertungen.py 227 Aggregations-Views Application
mail.py 220 SMTP + Digest Application + Infrastructure
models.py 180 Pydantic-DTOs Anämisches Domain-Model
drucksache_typen.py 88 Typ-Normalisierung Domain-Service
config.py 60 Settings Infrastructure
wahlperioden.py 52 WP-Metadaten Domain-Katalog
validators.py 47 Input-Validatoren Application
wahlprogramm_check.py 37 Fehlende-Programme-Prüfung (#128) Domain-Service
__init__.py 0
gesamt 11789

1.2 Konkrete DDD-Verletzungen (mit Zeilen-Referenzen)

A) Infrastructure-Leak in Domain-Funktion

analyzer.py:232-238

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 (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)

graph TD
    AB[Antragsbewertung<br/>**Core**]
    WP[Wahlprogramm-<br/>Wissensbasis<br/>Supporting]
    PI[Parlaments-<br/>integration<br/>Supporting]
    PU[Publikation<br/>Supporting]
    BE[Benutzer &<br/>Abonnement<br/>Generic]
    MO[Monitoring<br/>Generic]

    PI -->|"Drucksache<br/>(Customer-Supplier)"| AB
    WP -->|"Chunks + ZitatVerify<br/>(Customer-Supplier)"| AB
    AB -->|"Bewertung<br/>(Shared-Kernel)"| PU
    AB -->|"Bewertung<br/>(Conformist)"| BE
    BE -.->|"Filter-Abo<br/>(Conformist)"| PU
    PI -.->|"Scan-Events<br/>(Customer-Supplier)"| MO
    AB -.->|"Kosten-Schätzung<br/>(Published Language)"| MO

    classDef core fill:#009da5,stroke:#333,color:#fff
    classDef supporting fill:#889e33,stroke:#333,color:#fff
    classDef generic fill:#ccc,stroke:#333
    class AB core
    class WP,PI,PU supporting
    class BE,MO generic

Legende der Beziehungstypen:

  • Customer-Supplier (PI → AB, WP → AB): Antragsbewertung ist Downstream-Konsument. Heute keine Contract-Tests — Sub-D-Property-Tests aus ADR 0003 decken nur Zitat-Citations ab, nicht die Drucksachen-Shape.
  • Shared-Kernel (AB → PU): Assessment ist das Datenmodell, das sowohl Core als auch Publikation direkt benutzen. Das ist heute ein Schmerz: jede Pydantic-Feld-Umbenennung bricht Jinja-Templates. Alternative wäre Konversion zu eigenem Publikations-DTO.
  • Conformist (AB → BE, BE → PU): Benutzer-Kontext liest Bewertung roh, ohne eigene Übersetzung. Bei größerer Isolierung wünschenswert.
  • Anti-Corruption-Layer: implizit in jedem {BL}Adapter-Subclass — 17-fach vorhanden, strukturell aber nicht explizit ausgewiesen.
  • Published Language (AB ⇢ MO): Monitoring konsumiert nur eine stabile Teilmenge (Cost-Log); kann als publizierbares Event-Format formalisiert werden.

2.4 Anti-Corruption-Layer-Kandidaten (wo leakt Infrastructure heute)

  1. parlamente.py::Drucksache in Jinja-Templates. Dataclass mit Adapter-Feldern wird an templates/index.html durchgereicht (via main.py). Ein AntragDto pro Publikation wäre die saubere Lösung — die Publikation würde Drucksache nie direkt sehen.
  2. Assessment-Pydantic-Model direkt in Jinja. Kein Rendering-DTO. Felder wie gwoe_matrix werden im Template durchiteriert. Bei Pydantic-Aliase-Änderungen brechen Templates stumm.
  3. SQLite-Rows direkt als dict nach oben. get_assessment() (database.py:623) gibt dict(row) zurück, Routes konsumieren das. Kein Mapping zum Domain-Objekt dazwischen.
  4. Embeddings-Infrastruktur im Analyzer. analyzer.py:20-25 importiert direkt aus embeddings.py (EMBEDDINGS_DB, reconstruct_zitate). Der Kontext Antragsbewertung kennt ein DB-File und eine Rekonstruktions-Funktion der Wissensbasis.
  5. Keycloak-Rohformat in Kommentar-Sichtbarkeit. database.py:381 group:XYZ hat TODO-Kommentar „Keycloak-Gruppen-Membership prüfen" — keine Übersetzung der externen Gruppen-Kennung in eine Domain-Rolle.

3. Zielbild (nur als Referenz)

3.1 Ordner-Skelett (Ports-and-Adapters)

webapp/app/
├── antragsbewertung/               # Core
│   ├── domain/
│   │   ├── antrag.py               # Entity: Antrag
│   │   ├── bewertung.py            # Aggregate-Root + VOs
│   │   ├── gwoe_matrix.py          # Matrix 2.0 als Domain-Konzept
│   │   ├── empfehlung.py           # Empfehlung / Empfehlungssymbol
│   │   └── zitat.py                # VO Zitat (Verify-Regel)
│   ├── application/
│   │   ├── bewerte_antrag.py       # Use-Case
│   │   └── ports.py                # AntragRepository, LlmBewerter, ZitatVerifier
│   └── infrastructure/
│       ├── sqlite_bewertung_repo.py
│       ├── qwen_bewerter.py
│       └── embedding_zitat_verifier.py
├── wahlprogramm/
│   ├── domain/
│   │   ├── programm.py
│   │   ├── chunk.py
│   │   └── chunk_registry.py
│   ├── application/
│   │   └── ports.py                # EmbeddingStore, PdfReader
│   └── infrastructure/
│       ├── sqlite_chunk_store.py
│       └── dashscope_embedder.py
├── parlamentsintegration/
│   ├── domain/
│   │   ├── drucksache.py           # Entity
│   │   ├── drucksachen_nummer.py   # VO
│   │   ├── drucksachen_typ.py      # VO + Normalisierung
│   │   └── bundesland.py
│   ├── application/
│   │   ├── ports.py                # ParlamentAdapter (Protocol)
│   │   └── suche_antrag.py
│   └── infrastructure/
│       └── adapters/
│           ├── nrw_opal.py
│           ├── by_starweb.py
│           └── …                   # 1 Datei pro BL
├── benutzer/
├── publikation/
├── monitoring/
└── interfaces/
    ├── http/                       # FastAPI + Routes pro Kontext
    └── cli/                        # Scripts (reindex, digest, scan)

3.2 Beispiel 1 — Domain-Klasse mit Verhalten: Bewertung

# 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-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:

# 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ängigkeitenLlmBewerter-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 VerhaltenBewertung.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:

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:

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