ADR 0008: Lightweight-Migration ohne Package-Split
- ports/llm_bewerter.py: Protocol + LlmRequest-Dataclass
- adapters/qwen_bewerter.py: Qwen/DashScope-Adapter mit Retry-Loop
- repositories/{antrag,bewertung,abonnement}_repository.py: Protocol + Sqlite-Impl + InMemory-Fake
- analyzer.py refactored: nimmt Optional[LlmBewerter], AsyncOpenAI-Import raus
- models.py: 5 Domain-Methoden auf Bewertung/MatrixEntry
(ist_ablehnung, hat_fundamental_kritisches_feld, verletzt_score_cap, ...)
- analyzer loggt WARNING wenn LLM Score-Cap-Invariante verletzt
Folge-PR: Callsite-Migration in main.py (~21 direkte database.*-Aufrufe)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
154 lines
6.6 KiB
Markdown
154 lines
6.6 KiB
Markdown
# 0008 — DDD-Lightweight-Migration
|
||
|
||
| | |
|
||
|---|---|
|
||
| **Status** | accepted |
|
||
| **Datum** | 2026-04-20 |
|
||
| **Refs** | Issue #136, `docs/analysen/ddd-bewertung.md` Kap. 7 + 10 |
|
||
| **Related** | ADR 0001 (Citation-Binding), ADR 0002 (Adapter-Architektur) |
|
||
|
||
## Kontext
|
||
|
||
Nach 18 Monaten Entwicklung verteilt der Code sich auf 23 Dateien in
|
||
einem flachen `app/`-Verzeichnis (LOC-Inventar in `ddd-bewertung.md`
|
||
Kap. 1.1). Die Analyse in diesem Dokument zeigt fünf konkrete
|
||
DDD-Verletzungen (A–E, ebenda Kap. 1.2):
|
||
|
||
- **A** Infrastructure-Leak: `analyze_antrag` instanziiert direkt einen
|
||
`AsyncOpenAI`-Client.
|
||
- **B** Retry-Loop, JSON-Parsing und Pydantic in einer 60-Zeilen-Kaskade.
|
||
- **C** Anämisches Modell: `MatrixEntry` / `Assessment` tragen keine
|
||
Invarianten; die Score-Cap-Regel („rating ≤ -4 ⇒ score ≤ 3") lebt nur
|
||
im LLM-System-Prompt.
|
||
- **D** Kein Repository-Pattern: `database.py` (909 LOC) wird von sechs
|
||
Modulen direkt aufgerufen.
|
||
- **E** Adapter-Contract nur informell (abhandelbar in späterem PR).
|
||
|
||
Issue #136 fragte nach einer DDD-Umstellung. Die Analyse zeigte zwei
|
||
realistische Optionen (Kap. 7 des Bewertungsdokuments).
|
||
|
||
## Optionen
|
||
|
||
### Option A — Voll-DDD mit Package-Split
|
||
|
||
Getrennte Packages `antragsbewertung/`, `parlamentsintegration/`,
|
||
`wahlprogramm_wissensbasis/`, `publikation/`, `benutzer_abo/`,
|
||
`monitoring/`, jeweils mit `domain/application/infrastructure/`-
|
||
Struktur. Ports und Adapter überall, Anti-Corruption-Layer pro BL.
|
||
|
||
**Vorteile:** Maximale Testbarkeit, klare Bounded-Contexts, mehrere
|
||
Devs können parallel arbeiten, Ubiquitous-Language konsequent.
|
||
|
||
**Nachteile:** 4-8 Wochen netto Umbau, Test-Suite muss migriert werden,
|
||
hohes Regressionsrisiko während der Migration, für Solo-Projekt dieser
|
||
Größe ein schlechtes Kosten-Nutzen-Verhältnis.
|
||
|
||
### Option B — DDD-Lightweight
|
||
|
||
Drei gezielte DDD-Prinzipien *ohne* Package-Split: Repository-Pattern,
|
||
LLM-Port, Domain-Verhalten auf den bestehenden Pydantic-Modellen.
|
||
Dateien bleiben flach in `app/` liegen, nur drei neue Unterordner:
|
||
`repositories/`, `ports/`, `adapters/`.
|
||
|
||
**Vorteile:** Adressiert die schmerzhaftesten Punkte (A, B, C, D) in
|
||
5-8 Tagen. Keine API-Breaking-Changes. Callsites müssen nicht alle
|
||
gleichzeitig migrieren — die Repositories delegieren an die alten
|
||
`database.py`-Funktionen, alte Calls laufen weiter.
|
||
|
||
**Nachteile:** Parlaments-Adapter bleiben als eine 3397-LOC-Datei
|
||
(`parlamente.py`). Ubiquitous-Language bleibt halb Deutsch / halb
|
||
Englisch. Bounded-Contexts sind nur konzeptuell, nicht physisch
|
||
separiert.
|
||
|
||
## Entscheidung
|
||
|
||
**Option B**. Konkret:
|
||
|
||
1. **Repository-Pattern** für `assessments`, `assessment_versions` und
|
||
`email_subscriptions` — `app/repositories/`, drei Module.
|
||
2. **LLM-Port + Qwen-Adapter** — `app/ports/llm_bewerter.py`,
|
||
`app/adapters/qwen_bewerter.py`. `analyze_antrag` nimmt ein
|
||
`LlmBewerter`-Argument (Default: `QwenBewerter()`), direkt oder über
|
||
FastAPI-`Depends`.
|
||
3. **Domain-Verhalten** auf `Assessment` und `MatrixEntry`:
|
||
`verletzt_score_cap()`, `ist_ablehnung()`,
|
||
`ist_uneingeschraenkt_unterstuetzend()`, `hat_fundamental_kritisches_feld()`,
|
||
`MatrixEntry.ist_fundamental_kritisch()`, `MatrixEntry.to_symbol()`.
|
||
`analyzer.py` loggt bei Verletzung der Score-Cap-Invariante eine
|
||
Warning, wirft aber nicht — das LLM soll lernen, der Produktiv-
|
||
betrieb soll nicht brechen.
|
||
|
||
Nicht Teil dieser Entscheidung (Folge-PRs):
|
||
|
||
- Migration aller `database.*`-Callsites in `main.py` (21 Stellen)
|
||
auf Repository-`Depends`. Der Repository-Layer ist dafür
|
||
bereitgelegt; die Umstellung selbst ist mechanisch, aber wegen
|
||
1746 LOC in einer Datei mit Merge-Konflikt-Risiko verbunden.
|
||
- `ZitatVerifier`-Port (Tag 5 der Roadmap).
|
||
- Frozen-Dataclass-Domain-Objekte in `app/domain/` (Tag 6/7).
|
||
- Bounded-Context-Package-Split (explizit ausgeschlossen).
|
||
|
||
## Konsequenzen
|
||
|
||
### Positiv
|
||
|
||
- **Tests ohne OpenAI-Stub**. `conftest.py::_stub("openai")` ist nicht
|
||
mehr zwingend; Tests reichen einen `FakeLlmBewerter`-Objekt. Der
|
||
umgebaute `test_bug_regressions.py::test_analyzer_user_prompt_...`
|
||
ist exakt dieses Muster.
|
||
- **Adapter-Wechsel trivial**. Ein zweiter LLM-Provider (Claude,
|
||
Gemini) ist eine neue Klasse in `app/adapters/`, ohne Änderung in
|
||
`analyzer.py`.
|
||
- **Server-seitige Score-Cap-Erkennung**. Verstöße gegen die
|
||
fundamental-kritisch-Regel aus dem System-Prompt werden jetzt in
|
||
Logs sichtbar (`analyzer.py`), können in einem Folge-Issue zu
|
||
harten Rejects hochgezogen werden, wenn die Qualitäts-Daten stabil
|
||
aussehen.
|
||
- **Test-Fakes für DB-Zugriff**. Application-Logik-Tests brauchen
|
||
keine SQLite-Datei mehr; `InMemoryAntragRepository` reicht. Die
|
||
76 neuen Tests in `tests/test_*_repository.py`, `test_llm_bewerter.py`
|
||
und `test_domain_behavior.py` demonstrieren das.
|
||
|
||
### Negativ
|
||
|
||
- **Zwei Zugangswege zur DB parallel**. Solange `main.py` noch
|
||
`database.get_assessment` direkt aufruft, gibt es doppelten Zugriff.
|
||
Folge-PR räumt auf.
|
||
- **`analyzer.py` importiert noch `check_missing_programmes` direkt**.
|
||
Auch das ist Infrastructure, liegt aber außerhalb der drei
|
||
migrierten Schnitte. Nächster Schritt bei Bedarf.
|
||
- **Logger-Warning ist weich**. Wer die Logs nicht liest, bemerkt
|
||
Score-Cap-Verletzungen nicht. Ein Dashboard-Panel auf
|
||
`logger.name == "app.analyzer"` + Severity ≥ WARNING gehört in
|
||
die Monitoring-Erweiterung (#135-Folge).
|
||
|
||
### Folgen für andere ADRs
|
||
|
||
- **ADR 0001** (Citation-Binding) bleibt unverändert gültig — die
|
||
Post-LLM-Rekonstruktion läuft weiter nach `bewerter.bewerte()`.
|
||
- **ADR 0002** (Adapter-Architektur für Parlamente) bleibt unverändert;
|
||
die 17 Adapter in `parlamente.py` werden hier *nicht* in
|
||
`app/adapters/` gezogen. Ein Folge-ADR (0009?) könnte den Package-
|
||
Split für Parlaments-Adapter beschließen, wenn die Datei über 5000
|
||
LOC wächst (wie in ADR 0002 angekündigt).
|
||
- **ADR 0003** (Citation-Property-Tests) bleibt unverändert; die
|
||
Tests hängen an der Assessment-Schnittstelle, die sich hier nicht
|
||
geändert hat.
|
||
|
||
## Nach-Migration-Regeln
|
||
|
||
Zur Orientierung bei neuen Beiträgen:
|
||
|
||
1. **Kein direktes `database.*` in neuer Application-Logik.** Immer
|
||
über das passende Repository.
|
||
2. **Kein `AsyncOpenAI` außerhalb `app/adapters/qwen_bewerter.py`.**
|
||
Neue LLM-Provider bekommen einen eigenen Adapter im gleichen
|
||
Ordner.
|
||
3. **Invarianten auf dem Domain-Modell, nicht im Prompt.** Wenn eine
|
||
Regel im LLM-System-Prompt steht und vom LLM potenziell verletzt
|
||
werden kann, gehört eine Prüfmethode auf `Assessment` oder
|
||
`MatrixEntry` und ein Warning-Log in `analyzer.py`.
|
||
4. **Tests mit `InMemory*Repository` und `FakeLlmBewerter`.** Keine
|
||
Monkeypatches auf `app.analyzer.AsyncOpenAI` mehr; keine
|
||
temporären SQLite-Dateien für Unit-Tests.
|