blob: 76b492f7996ebeed080f2544bae16429a1f63fab [file] [log] [blame]
Matthias Andreas Benkard279fca62026-08-23 08:32:08 +02001// SPDX-FileCopyrightText: 2020 Matthias Andreas Benkard <code@mail.matthias.benkard.de>
2// SPDX-License-Identifier: AGPL-3.0-or-later
Matthias Andreas Benkard7b455cd2026-08-09 08:33:37 +02003package eu.mulk.aendggner;
4
Matthias Andreas Benkard7235b342026-08-14 18:58:25 +02005import eu.mulk.aendggner.aenderung.DokumentArt;
6import eu.mulk.aendggner.aenderung.DokumentKopf;
7import eu.mulk.aendggner.aenderung.parse.AenderungsantragParser;
Matthias Andreas Benkard7b455cd2026-08-09 08:33:37 +02008import eu.mulk.aendggner.aenderung.parse.AenderungsgesetzParser;
Matthias Andreas Benkard7235b342026-08-14 18:58:25 +02009import eu.mulk.aendggner.aenderung.parse.DokumentErkenner;
10import eu.mulk.aendggner.aenderung.parse.EntwurfsPatcher;
Matthias Andreas Benkard7b455cd2026-08-09 08:33:37 +020011import eu.mulk.aendggner.aenderung.parse.PatchTextExtraktor;
12import eu.mulk.aendggner.aenderung.parse.SuperskriptModus;
13import eu.mulk.aendggner.aenderung.parse.TextBereiniger;
14import eu.mulk.aendggner.anwendung.BefehlAnwender;
15import eu.mulk.aendggner.gesetz.Gesetz;
16import eu.mulk.aendggner.gesetz.Superskript;
17import eu.mulk.aendggner.gesetz.gii.GiiXmlLoader;
18import eu.mulk.aendggner.gesetz.land.LandesRechtLoader;
19import eu.mulk.aendggner.synopse.HtmlRenderer;
20import eu.mulk.aendggner.synopse.SynopseBuilder;
Matthias Andreas Benkard7b455cd2026-08-09 08:33:37 +020021import java.nio.file.Path;
22import java.util.ArrayList;
23import java.util.List;
Matthias Andreas Benkardffc553b2026-08-15 12:18:32 +020024import org.jspecify.annotations.Nullable;
Matthias Andreas Benkard7b455cd2026-08-09 08:33:37 +020025
26/**
27 * Kernpipeline: Stammgesetz laden → Änderungsgesetze parsen und anwenden → Synopse rendern.
28 *
Matthias Andreas Benkard2b1841e2026-08-15 10:51:11 +020029 * <p>Wird sowohl von der Befehlszeile ({@link AendGgner}) als auch von der Browserfassung ({@code
30 * eu.mulk.aendggner.wasm.BrowserMain}) verwendet, damit die Anwendungslogik nur an einer Stelle
31 * existiert. Sie kennt kein Dateisystem: Eingaben kommen als {@link Quelle} (Name und Bytes).
Matthias Andreas Benkard7b455cd2026-08-09 08:33:37 +020032 */
33public final class Pipeline {
34
Matthias Andreas Benkard7235b342026-08-14 18:58:25 +020035 private static final org.jboss.logging.Logger log =
36 org.jboss.logging.Logger.getLogger(Pipeline.class);
37
Matthias Andreas Benkard7b455cd2026-08-09 08:33:37 +020038 private Pipeline() {}
39
40 public record Ergebnis(
41 String html,
42 long anzahlAngewandt,
43 long anzahlManuell,
44 int anzahlGeaenderteNormen,
45 int anzahlProtokollEintraege) {}
46
Matthias Andreas Benkard7235b342026-08-14 18:58:25 +020047 /**
48 * Ein eingespeistes Änderungsdokument samt erkannter Art und aufbereitetem Text.
49 *
50 * @param eingearbeitet die Dokumente, die vor der Anwendung in {@code text} eingearbeitet wurden
51 * — bei einem Entwurf die Änderungsanträge, die ihn geändert haben. Sie gehen in die
52 * Quellenzeile ein, denn die gezeigte Fassung ist ohne sie nicht nachvollziehbar.
Matthias Andreas Benkardffc553b2026-08-15 12:18:32 +020053 * @param fassung welche von mehreren Fassungen des Dokuments gilt, sofern es mehrere trägt — die
54 * Beschlussempfehlung stellt Entwurf und Ausschussfassung nebeneinander. Sonst {@code null}.
Matthias Andreas Benkard7235b342026-08-14 18:58:25 +020055 */
Matthias Andreas Benkardffc553b2026-08-15 12:18:32 +020056 record Quelldokument(
57 Quelle quelle,
58 DokumentKopf kopf,
59 String text,
60 List<String> eingearbeitet,
61 @Nullable String fassung) {
Matthias Andreas Benkard7235b342026-08-14 18:58:25 +020062
Matthias Andreas Benkard2b1841e2026-08-15 10:51:11 +020063 Quelldokument(Quelle quelle, DokumentKopf kopf, String text) {
Matthias Andreas Benkardffc553b2026-08-15 12:18:32 +020064 this(quelle, kopf, text, List.of(), null);
65 }
66
67 Quelldokument(Quelle quelle, DokumentKopf kopf, String text, List<String> eingearbeitet) {
68 this(quelle, kopf, text, eingearbeitet, null);
Matthias Andreas Benkard7235b342026-08-14 18:58:25 +020069 }
70
71 String quellenAngabe(List<String> artikel) {
Matthias Andreas Benkard2b1841e2026-08-15 10:51:11 +020072 var sb = new StringBuilder(quelle.name());
Matthias Andreas Benkardffc553b2026-08-15 12:18:32 +020073 sb.append(" [").append(kopf.anzeigeName());
74 if (fassung != null) {
75 sb.append(", ").append(fassung);
76 }
77 sb.append("]");
Matthias Andreas Benkard7235b342026-08-14 18:58:25 +020078 for (var zusatz : eingearbeitet) {
79 sb.append(" + ").append(zusatz);
80 }
81 return sb.append(" (Artikel ").append(String.join(", ", artikel)).append(")").toString();
82 }
83 }
84
Matthias Andreas Benkard2b1841e2026-08-15 10:51:11 +020085 /** Bequemlichkeit für Befehlszeile und Tests; im Browser gibt es keine {@link Path}e. */
Matthias Andreas Benkard7b455cd2026-08-09 08:33:37 +020086 public static Ergebnis erzeugeSynopse(
87 Path baseFile, List<Path> patches, String artikel, boolean vollstaendig) throws Exception {
Matthias Andreas Benkard2b1841e2026-08-15 10:51:11 +020088 var patchQuellen = new ArrayList<Quelle>();
89 for (var patch : patches) {
90 patchQuellen.add(Quelle.lies(patch));
91 }
92 return erzeugeSynopse(Quelle.lies(baseFile), patchQuellen, artikel, vollstaendig);
93 }
94
95 public static Ergebnis erzeugeSynopse(
96 Quelle baseFile, List<Quelle> patches, String artikel, boolean vollstaendig)
97 throws Exception {
Matthias Andreas Benkard7b455cd2026-08-09 08:33:37 +020098 var altesGesetz = ladeStammgesetz(baseFile);
99 var extraktor = new PatchTextExtraktor(superskriptModus(altesGesetz));
100 var parser = new AenderungsgesetzParser();
101
Matthias Andreas Benkard7235b342026-08-14 18:58:25 +0200102 var warnungen = new ArrayList<String>();
103 var dokumente = wendeAntraegeAn(leseDokumente(patches, extraktor, warnungen), warnungen);
104
Matthias Andreas Benkard7b455cd2026-08-09 08:33:37 +0200105 var gesetz = altesGesetz;
106 var protokoll = new ArrayList<BefehlAnwender.AngewandteAenderung>();
Matthias Andreas Benkard7b455cd2026-08-09 08:33:37 +0200107 var quellen = new ArrayList<String>();
Matthias Andreas Benkard7235b342026-08-14 18:58:25 +0200108 boolean entwurfsfassung = false;
Matthias Andreas Benkard7b455cd2026-08-09 08:33:37 +0200109
Matthias Andreas Benkard7235b342026-08-14 18:58:25 +0200110 for (var dokument : dokumente) {
111 var parseErgebnis = parser.parse(dokument.text(), gesetz, artikel, entwurfsGrenzen(dokument));
Matthias Andreas Benkard7b455cd2026-08-09 08:33:37 +0200112 if (parseErgebnis.befehle().isEmpty()) {
Matthias Andreas Benkard7235b342026-08-14 18:58:25 +0200113 warnungen.add(
114 "In %s (%s) wurde kein auf %s anwendbarer Artikel gefunden."
115 .formatted(
Matthias Andreas Benkard2b1841e2026-08-15 10:51:11 +0200116 dokument.quelle().name(), dokument.kopf().anzeigeName(), gesetz.jurabk()));
Matthias Andreas Benkard7b455cd2026-08-09 08:33:37 +0200117 }
118 var anwendung = BefehlAnwender.anwenden(gesetz, parseErgebnis.befehle());
119 gesetz = anwendung.neu();
120 protokoll.addAll(anwendung.protokoll());
121 warnungen.addAll(parseErgebnis.warnungen());
Matthias Andreas Benkard7235b342026-08-14 18:58:25 +0200122 entwurfsfassung |= dokument.kopf().art().istEntwurfsfassung();
123 quellen.add(dokument.quellenAngabe(parseErgebnis.artikel()));
Matthias Andreas Benkard7b455cd2026-08-09 08:33:37 +0200124 }
125
126 var gesamtErgebnis = new BefehlAnwender.AnwendungsErgebnis(gesetz, protokoll);
127 var synopse = SynopseBuilder.baue(altesGesetz, gesamtErgebnis, warnungen, vollstaendig);
Matthias Andreas Benkard2b1841e2026-08-15 10:51:11 +0200128 var quellenZeile = baseFile.name() + " + " + String.join(" + ", quellen);
129 var html = HtmlRenderer.rendere(synopse, quellenZeile, entwurfsfassung);
Matthias Andreas Benkard7b455cd2026-08-09 08:33:37 +0200130
131 return new Ergebnis(
132 html,
133 gesamtErgebnis.anzahlAngewandt(),
134 gesamtErgebnis.anzahlManuell(),
135 synopse.eintraege().size(),
136 protokoll.size());
137 }
138
Matthias Andreas Benkard7235b342026-08-14 18:58:25 +0200139 /**
140 * Liest die Änderungsdokumente ein, bestimmt ihre Art und sortiert die aus, aus denen keine
141 * Synopse zu gewinnen ist. Verworfen wird nichts stillschweigend: Jedes ausgesonderte Dokument
142 * hinterlässt eine Warnung, die in der Synopse erscheint.
143 */
144 private static List<Quelldokument> leseDokumente(
Matthias Andreas Benkard2b1841e2026-08-15 10:51:11 +0200145 List<Quelle> patches, PatchTextExtraktor extraktor, List<String> warnungen) throws Exception {
Matthias Andreas Benkard7235b342026-08-14 18:58:25 +0200146 var dokumente = new ArrayList<Quelldokument>();
147 for (var datei : patches) {
148 var rohText = extraktor.extrahiere(datei);
149 // Die Erkennung arbeitet auf dem Rohtext: Der Bereiniger entfernt genau die
150 // Drucksachenköpfe, aus denen Art und Nummer hervorgehen.
151 var kopf = DokumentErkenner.erkenne(rohText);
Matthias Andreas Benkard2b1841e2026-08-15 10:51:11 +0200152 log.infof("Datei %s erkannt als %s.", datei.name(), kopf.anzeigeName());
Matthias Andreas Benkard7235b342026-08-14 18:58:25 +0200153 if (kopf.art() == DokumentArt.OHNE_BEFEHLE) {
154 warnungen.add(
155 "%s ist ein %s und enthält keine Änderungsbefehle; die Datei wurde übergangen."
Matthias Andreas Benkard2b1841e2026-08-15 10:51:11 +0200156 .formatted(datei.name(), kopf.art().anzeigeName()));
Matthias Andreas Benkard7235b342026-08-14 18:58:25 +0200157 continue;
158 }
159 if (kopf.art() == DokumentArt.BESCHLUSSEMPFEHLUNG) {
Matthias Andreas Benkardffc553b2026-08-15 12:18:32 +0200160 // Die maßgebliche Fassung steht nicht im Fließtext, sondern in der zweispaltigen
161 // Zusammenstellung: links der Entwurf, rechts die Beschlüsse des Ausschusses.
162 var fassung = extraktor.leseZusammenstellung(datei);
163 warnungen.addAll(fassung.warnungen());
164 if (fassung.text() == null) {
165 // Eine halb aufgelöste Fassung auszugeben wäre schlimmer als keine.
166 warnungen.add(
167 ("%s ist eine Beschlussempfehlung, deren Zusammenstellung sich nicht auflösen ließ;"
168 + " die Datei wurde übergangen. Für eine Synopse eignet sich der zugrunde"
169 + " liegende Gesetzentwurf%s.")
170 .formatted(
171 datei.name(),
172 kopf.bezugsDrucksachen().isEmpty()
173 ? ""
174 : " (Drs. " + kopf.bezugsDrucksachen().get(0) + ")"));
175 continue;
176 }
177 dokumente.add(
178 new Quelldokument(
179 datei,
180 kopf,
181 TextBereiniger.bereinige(fassung.text()),
182 List.of(),
183 "Ausschussfassung"));
Matthias Andreas Benkard7235b342026-08-14 18:58:25 +0200184 continue;
185 }
186 dokumente.add(new Quelldokument(datei, kopf, TextBereiniger.bereinige(rohText)));
187 }
188 return dokumente;
189 }
190
191 /**
192 * Wendet jeden Änderungsantrag auf den Entwurf an, den er ändern will, und nimmt ihn aus der
193 * Liste: Was danach bleibt, sind lauter Dokumente, die unmittelbar das Stammgesetz ändern.
194 *
195 * <p>Zugeordnet wird über die Drucksachennummer, die der Antrag selbst nennt („(Drs. 19/9707)“).
196 * Nur wo die fehlt, entscheidet die Reihenfolge der Argumente — der zuletzt genannte Entwurf
197 * davor. Findet sich gar kein Entwurf, bleibt der Antrag unangewandt und wird gemeldet; ihn
198 * ersatzweise auf das Stammgesetz loszulassen wäre falsch, denn seine Stellenangaben zielen auf
199 * die Drucksache.
200 */
201 private static List<Quelldokument> wendeAntraegeAn(
202 List<Quelldokument> dokumente, List<String> warnungen) {
203 if (dokumente.stream().noneMatch(d -> d.kopf().art() == DokumentArt.AENDERUNGSANTRAG)) {
204 return dokumente;
205 }
206 var ergebnis = new ArrayList<>(dokumente);
207 for (var antrag : dokumente) {
208 if (antrag.kopf().art() != DokumentArt.AENDERUNGSANTRAG) {
209 continue;
210 }
211 ergebnis.remove(antrag);
212 int zielIndex = findeEntwurf(ergebnis, antrag, dokumente.indexOf(antrag));
213 if (zielIndex < 0) {
214 warnungen.add(
215 ("%s ist ein Änderungsantrag zu %s; der zugehörige Gesetzentwurf wurde nicht"
216 + " mitgegeben, der Antrag blieb daher unberücksichtigt.")
217 .formatted(
Matthias Andreas Benkard2b1841e2026-08-15 10:51:11 +0200218 antrag.quelle().name(),
Matthias Andreas Benkard7235b342026-08-14 18:58:25 +0200219 antrag.kopf().bezugsDrucksachen().isEmpty()
220 ? "einer Drucksache"
221 : "Drs. " + String.join(", ", antrag.kopf().bezugsDrucksachen())));
222 continue;
223 }
224 var ziel = ergebnis.get(zielIndex);
225 var parseErgebnis = AenderungsantragParser.parse(antrag.text());
226 warnungen.addAll(parseErgebnis.warnungen());
227 var patch = EntwurfsPatcher.wendeAn(ziel.text(), parseErgebnis.befehle());
228 warnungen.addAll(patch.warnungen());
229 log.infof(
230 "%s: %d von %d Antragsbefehlen auf %s angewandt.",
Matthias Andreas Benkard2b1841e2026-08-15 10:51:11 +0200231 antrag.quelle().name(),
Matthias Andreas Benkard7235b342026-08-14 18:58:25 +0200232 patch.angewandt(),
233 parseErgebnis.befehle().size(),
Matthias Andreas Benkard2b1841e2026-08-15 10:51:11 +0200234 ziel.quelle().name());
Matthias Andreas Benkard7235b342026-08-14 18:58:25 +0200235 var eingearbeitet = new ArrayList<>(ziel.eingearbeitet());
Matthias Andreas Benkard2b1841e2026-08-15 10:51:11 +0200236 eingearbeitet.add(antrag.quelle().name() + " [" + antrag.kopf().anzeigeName() + "]");
Matthias Andreas Benkard7235b342026-08-14 18:58:25 +0200237 ergebnis.set(
238 zielIndex,
Matthias Andreas Benkardffc553b2026-08-15 12:18:32 +0200239 new Quelldokument(
240 ziel.quelle(),
241 ziel.kopf(),
242 patch.text(),
243 List.copyOf(eingearbeitet),
244 ziel.fassung()));
Matthias Andreas Benkard7235b342026-08-14 18:58:25 +0200245 }
246 return ergebnis;
247 }
248
249 /** Der Index des Entwurfs, den {@code antrag} ändert; {@code -1}, wenn keiner dabei ist. */
250 private static int findeEntwurf(
251 List<Quelldokument> dokumente, Quelldokument antrag, int antragsPosition) {
252 for (int i = 0; i < dokumente.size(); i++) {
253 var kopf = dokumente.get(i).kopf();
254 if (kopf.eigeneDrucksache() != null
255 && antrag.kopf().bezugsDrucksachen().contains(kopf.eigeneDrucksache())) {
256 return i;
257 }
258 }
259 int letzter = -1;
260 for (int i = 0; i < dokumente.size() && i < antragsPosition; i++) {
261 if (dokumente.get(i).kopf().art() == DokumentArt.GESETZENTWURF) {
262 letzter = i;
263 }
264 }
265 return letzter;
266 }
267
268 /**
269 * Entwürfe tragen hinter dem Regelungstext einen Begründungsteil, dessen Freitext keine Befehle
270 * enthält und den letzten Artikel nicht verunreinigen darf. Verkündete Gesetze haben ihn nicht —
271 * dort bliebe die Suche nach Begründungsmarken folgenlos, aber sie unterbleibt trotzdem, damit
272 * ein Gesetzblatt nicht an einem gleichlautenden Wort abbricht.
273 */
274 private static boolean entwurfsGrenzen(Quelldokument dokument) {
275 return dokument.kopf().art().istEntwurfsfassung();
276 }
277
Matthias Andreas Benkard2b1841e2026-08-15 10:51:11 +0200278 /** Bequemlichkeit für Befehlszeile und Tests; im Browser gibt es keine {@link Path}e. */
Matthias Andreas Benkard7b455cd2026-08-09 08:33:37 +0200279 static Gesetz ladeStammgesetz(Path baseFile) throws Exception {
Matthias Andreas Benkard2b1841e2026-08-15 10:51:11 +0200280 return ladeStammgesetz(Quelle.lies(baseFile));
281 }
282
Matthias Andreas Benkard06da70e2026-08-16 07:34:12 +0200283 /**
284 * Gii-XML → {@link GiiXmlLoader}; PDF/Klartext (Landesrecht) → {@link LandesRechtLoader}.
285 *
286 * <p>Vorweg wird ausgepackt: gesetze-im-internet.de gibt das Norm-XML nur als {@code xml.zip}
287 * aus, und das soll unentpackt taugen (siehe {@link ZipAuspacker}). Die Änderungsdokumente
288 * bleiben davon unberührt — Gesetzblätter und Drucksachen kommen nirgends als Archiv.
289 */
Matthias Andreas Benkard2b1841e2026-08-15 10:51:11 +0200290 static Gesetz ladeStammgesetz(Quelle baseFile) throws Exception {
Matthias Andreas Benkard06da70e2026-08-16 07:34:12 +0200291 var quelle = ZipAuspacker.auspacken(baseFile);
292 return DateiTyp.erkenne(quelle.inhalt()) == DateiTyp.XML
293 ? new GiiXmlLoader().load(quelle)
294 : new LandesRechtLoader().load(quelle);
Matthias Andreas Benkard7b455cd2026-08-09 08:33:37 +0200295 }
296
297 /**
298 * Der Superskriptmodus folgt der Schreibweise des Stammgesetzes: Trägt es amtliche Satznummern
299 * (bayerisches Landesrecht, Niedersachsen u.a.), werden auch die Änderungsgesetze mit
300 * Superskript-Erhalt extrahiert, damit Zitate und Stammtext dieselbe Schreibweise tragen; sonst
Matthias Andreas Benkard7235b342026-08-14 18:58:25 +0200301 * (Bundesrecht, Länder ohne amtliche Satzzählung) sind hochgestellte Ziffern bloße Fußnotenmarker
302 * und werden verworfen.
Matthias Andreas Benkard7b455cd2026-08-09 08:33:37 +0200303 */
304 static SuperskriptModus superskriptModus(Gesetz gesetz) {
305 for (var norm : gesetz.normen()) {
306 for (var absatz : norm.absaetze()) {
307 if (Superskript.traegtSatznummern(absatz.text())) {
308 return SuperskriptModus.BEHALTEN;
309 }
310 }
311 }
312 return SuperskriptModus.ENTFERNEN;
313 }
Matthias Andreas Benkard7b455cd2026-08-09 08:33:37 +0200314}