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