blob: 16df2756049373af3a1dc2570b61b6b6d3ac4d0f [file] [log] [blame]
Matthias Andreas Benkard7b455cd2026-08-09 08:33:37 +02001package eu.mulk.aendggner;
2
Matthias Andreas Benkard7235b342026-08-14 18:58:25 +02003import eu.mulk.aendggner.aenderung.DokumentArt;
4import eu.mulk.aendggner.aenderung.DokumentKopf;
5import eu.mulk.aendggner.aenderung.parse.AenderungsantragParser;
Matthias Andreas Benkard7b455cd2026-08-09 08:33:37 +02006import eu.mulk.aendggner.aenderung.parse.AenderungsgesetzParser;
Matthias Andreas Benkard7235b342026-08-14 18:58:25 +02007import eu.mulk.aendggner.aenderung.parse.DokumentErkenner;
8import eu.mulk.aendggner.aenderung.parse.EntwurfsPatcher;
Matthias Andreas Benkard7b455cd2026-08-09 08:33:37 +02009import eu.mulk.aendggner.aenderung.parse.PatchTextExtraktor;
10import eu.mulk.aendggner.aenderung.parse.SuperskriptModus;
11import eu.mulk.aendggner.aenderung.parse.TextBereiniger;
12import eu.mulk.aendggner.anwendung.BefehlAnwender;
13import eu.mulk.aendggner.gesetz.Gesetz;
14import eu.mulk.aendggner.gesetz.Superskript;
15import eu.mulk.aendggner.gesetz.gii.GiiXmlLoader;
16import eu.mulk.aendggner.gesetz.land.LandesRechtLoader;
17import eu.mulk.aendggner.synopse.HtmlRenderer;
18import eu.mulk.aendggner.synopse.SynopseBuilder;
Matthias Andreas Benkard7b455cd2026-08-09 08:33:37 +020019import java.nio.file.Path;
20import java.util.ArrayList;
21import java.util.List;
Matthias Andreas Benkardffc553b2026-08-15 12:18:32 +020022import org.jspecify.annotations.Nullable;
Matthias Andreas Benkard7b455cd2026-08-09 08:33:37 +020023
24/**
25 * Kernpipeline: Stammgesetz laden → Änderungsgesetze parsen und anwenden → Synopse rendern.
26 *
Matthias Andreas Benkard2b1841e2026-08-15 10:51:11 +020027 * <p>Wird sowohl von der Befehlszeile ({@link AendGgner}) als auch von der Browserfassung ({@code
28 * eu.mulk.aendggner.wasm.BrowserMain}) verwendet, damit die Anwendungslogik nur an einer Stelle
29 * existiert. Sie kennt kein Dateisystem: Eingaben kommen als {@link Quelle} (Name und Bytes).
Matthias Andreas Benkard7b455cd2026-08-09 08:33:37 +020030 */
31public final class Pipeline {
32
Matthias Andreas Benkard7235b342026-08-14 18:58:25 +020033 private static final org.jboss.logging.Logger log =
34 org.jboss.logging.Logger.getLogger(Pipeline.class);
35
Matthias Andreas Benkard7b455cd2026-08-09 08:33:37 +020036 private Pipeline() {}
37
38 public record Ergebnis(
39 String html,
40 long anzahlAngewandt,
41 long anzahlManuell,
42 int anzahlGeaenderteNormen,
43 int anzahlProtokollEintraege) {}
44
Matthias Andreas Benkard7235b342026-08-14 18:58:25 +020045 /**
46 * Ein eingespeistes Änderungsdokument samt erkannter Art und aufbereitetem Text.
47 *
48 * @param eingearbeitet die Dokumente, die vor der Anwendung in {@code text} eingearbeitet wurden
49 * — bei einem Entwurf die Änderungsanträge, die ihn geändert haben. Sie gehen in die
50 * Quellenzeile ein, denn die gezeigte Fassung ist ohne sie nicht nachvollziehbar.
Matthias Andreas Benkardffc553b2026-08-15 12:18:32 +020051 * @param fassung welche von mehreren Fassungen des Dokuments gilt, sofern es mehrere trägt — die
52 * Beschlussempfehlung stellt Entwurf und Ausschussfassung nebeneinander. Sonst {@code null}.
Matthias Andreas Benkard7235b342026-08-14 18:58:25 +020053 */
Matthias Andreas Benkardffc553b2026-08-15 12:18:32 +020054 record Quelldokument(
55 Quelle quelle,
56 DokumentKopf kopf,
57 String text,
58 List<String> eingearbeitet,
59 @Nullable String fassung) {
Matthias Andreas Benkard7235b342026-08-14 18:58:25 +020060
Matthias Andreas Benkard2b1841e2026-08-15 10:51:11 +020061 Quelldokument(Quelle quelle, DokumentKopf kopf, String text) {
Matthias Andreas Benkardffc553b2026-08-15 12:18:32 +020062 this(quelle, kopf, text, List.of(), null);
63 }
64
65 Quelldokument(Quelle quelle, DokumentKopf kopf, String text, List<String> eingearbeitet) {
66 this(quelle, kopf, text, eingearbeitet, null);
Matthias Andreas Benkard7235b342026-08-14 18:58:25 +020067 }
68
69 String quellenAngabe(List<String> artikel) {
Matthias Andreas Benkard2b1841e2026-08-15 10:51:11 +020070 var sb = new StringBuilder(quelle.name());
Matthias Andreas Benkardffc553b2026-08-15 12:18:32 +020071 sb.append(" [").append(kopf.anzeigeName());
72 if (fassung != null) {
73 sb.append(", ").append(fassung);
74 }
75 sb.append("]");
Matthias Andreas Benkard7235b342026-08-14 18:58:25 +020076 for (var zusatz : eingearbeitet) {
77 sb.append(" + ").append(zusatz);
78 }
79 return sb.append(" (Artikel ").append(String.join(", ", artikel)).append(")").toString();
80 }
81 }
82
Matthias Andreas Benkard2b1841e2026-08-15 10:51:11 +020083 /** Bequemlichkeit für Befehlszeile und Tests; im Browser gibt es keine {@link Path}e. */
Matthias Andreas Benkard7b455cd2026-08-09 08:33:37 +020084 public static Ergebnis erzeugeSynopse(
85 Path baseFile, List<Path> patches, String artikel, boolean vollstaendig) throws Exception {
Matthias Andreas Benkard2b1841e2026-08-15 10:51:11 +020086 var patchQuellen = new ArrayList<Quelle>();
87 for (var patch : patches) {
88 patchQuellen.add(Quelle.lies(patch));
89 }
90 return erzeugeSynopse(Quelle.lies(baseFile), patchQuellen, artikel, vollstaendig);
91 }
92
93 public static Ergebnis erzeugeSynopse(
94 Quelle baseFile, List<Quelle> patches, String artikel, boolean vollstaendig)
95 throws Exception {
Matthias Andreas Benkard7b455cd2026-08-09 08:33:37 +020096 var altesGesetz = ladeStammgesetz(baseFile);
97 var extraktor = new PatchTextExtraktor(superskriptModus(altesGesetz));
98 var parser = new AenderungsgesetzParser();
99
Matthias Andreas Benkard7235b342026-08-14 18:58:25 +0200100 var warnungen = new ArrayList<String>();
101 var dokumente = wendeAntraegeAn(leseDokumente(patches, extraktor, warnungen), warnungen);
102
Matthias Andreas Benkard7b455cd2026-08-09 08:33:37 +0200103 var gesetz = altesGesetz;
104 var protokoll = new ArrayList<BefehlAnwender.AngewandteAenderung>();
Matthias Andreas Benkard7b455cd2026-08-09 08:33:37 +0200105 var quellen = new ArrayList<String>();
Matthias Andreas Benkard7235b342026-08-14 18:58:25 +0200106 boolean entwurfsfassung = false;
Matthias Andreas Benkard7b455cd2026-08-09 08:33:37 +0200107
Matthias Andreas Benkard7235b342026-08-14 18:58:25 +0200108 for (var dokument : dokumente) {
109 var parseErgebnis = parser.parse(dokument.text(), gesetz, artikel, entwurfsGrenzen(dokument));
Matthias Andreas Benkard7b455cd2026-08-09 08:33:37 +0200110 if (parseErgebnis.befehle().isEmpty()) {
Matthias Andreas Benkard7235b342026-08-14 18:58:25 +0200111 warnungen.add(
112 "In %s (%s) wurde kein auf %s anwendbarer Artikel gefunden."
113 .formatted(
Matthias Andreas Benkard2b1841e2026-08-15 10:51:11 +0200114 dokument.quelle().name(), dokument.kopf().anzeigeName(), gesetz.jurabk()));
Matthias Andreas Benkard7b455cd2026-08-09 08:33:37 +0200115 }
116 var anwendung = BefehlAnwender.anwenden(gesetz, parseErgebnis.befehle());
117 gesetz = anwendung.neu();
118 protokoll.addAll(anwendung.protokoll());
119 warnungen.addAll(parseErgebnis.warnungen());
Matthias Andreas Benkard7235b342026-08-14 18:58:25 +0200120 entwurfsfassung |= dokument.kopf().art().istEntwurfsfassung();
121 quellen.add(dokument.quellenAngabe(parseErgebnis.artikel()));
Matthias Andreas Benkard7b455cd2026-08-09 08:33:37 +0200122 }
123
124 var gesamtErgebnis = new BefehlAnwender.AnwendungsErgebnis(gesetz, protokoll);
125 var synopse = SynopseBuilder.baue(altesGesetz, gesamtErgebnis, warnungen, vollstaendig);
Matthias Andreas Benkard2b1841e2026-08-15 10:51:11 +0200126 var quellenZeile = baseFile.name() + " + " + String.join(" + ", quellen);
127 var html = HtmlRenderer.rendere(synopse, quellenZeile, entwurfsfassung);
Matthias Andreas Benkard7b455cd2026-08-09 08:33:37 +0200128
129 return new Ergebnis(
130 html,
131 gesamtErgebnis.anzahlAngewandt(),
132 gesamtErgebnis.anzahlManuell(),
133 synopse.eintraege().size(),
134 protokoll.size());
135 }
136
Matthias Andreas Benkard7235b342026-08-14 18:58:25 +0200137 /**
138 * Liest die Änderungsdokumente ein, bestimmt ihre Art und sortiert die aus, aus denen keine
139 * Synopse zu gewinnen ist. Verworfen wird nichts stillschweigend: Jedes ausgesonderte Dokument
140 * hinterlässt eine Warnung, die in der Synopse erscheint.
141 */
142 private static List<Quelldokument> leseDokumente(
Matthias Andreas Benkard2b1841e2026-08-15 10:51:11 +0200143 List<Quelle> patches, PatchTextExtraktor extraktor, List<String> warnungen) throws Exception {
Matthias Andreas Benkard7235b342026-08-14 18:58:25 +0200144 var dokumente = new ArrayList<Quelldokument>();
145 for (var datei : patches) {
146 var rohText = extraktor.extrahiere(datei);
147 // Die Erkennung arbeitet auf dem Rohtext: Der Bereiniger entfernt genau die
148 // Drucksachenköpfe, aus denen Art und Nummer hervorgehen.
149 var kopf = DokumentErkenner.erkenne(rohText);
Matthias Andreas Benkard2b1841e2026-08-15 10:51:11 +0200150 log.infof("Datei %s erkannt als %s.", datei.name(), kopf.anzeigeName());
Matthias Andreas Benkard7235b342026-08-14 18:58:25 +0200151 if (kopf.art() == DokumentArt.OHNE_BEFEHLE) {
152 warnungen.add(
153 "%s ist ein %s und enthält keine Änderungsbefehle; die Datei wurde übergangen."
Matthias Andreas Benkard2b1841e2026-08-15 10:51:11 +0200154 .formatted(datei.name(), kopf.art().anzeigeName()));
Matthias Andreas Benkard7235b342026-08-14 18:58:25 +0200155 continue;
156 }
157 if (kopf.art() == DokumentArt.BESCHLUSSEMPFEHLUNG) {
Matthias Andreas Benkardffc553b2026-08-15 12:18:32 +0200158 // Die maßgebliche Fassung steht nicht im Fließtext, sondern in der zweispaltigen
159 // Zusammenstellung: links der Entwurf, rechts die Beschlüsse des Ausschusses.
160 var fassung = extraktor.leseZusammenstellung(datei);
161 warnungen.addAll(fassung.warnungen());
162 if (fassung.text() == null) {
163 // Eine halb aufgelöste Fassung auszugeben wäre schlimmer als keine.
164 warnungen.add(
165 ("%s ist eine Beschlussempfehlung, deren Zusammenstellung sich nicht auflösen ließ;"
166 + " die Datei wurde übergangen. Für eine Synopse eignet sich der zugrunde"
167 + " liegende Gesetzentwurf%s.")
168 .formatted(
169 datei.name(),
170 kopf.bezugsDrucksachen().isEmpty()
171 ? ""
172 : " (Drs. " + kopf.bezugsDrucksachen().get(0) + ")"));
173 continue;
174 }
175 dokumente.add(
176 new Quelldokument(
177 datei,
178 kopf,
179 TextBereiniger.bereinige(fassung.text()),
180 List.of(),
181 "Ausschussfassung"));
Matthias Andreas Benkard7235b342026-08-14 18:58:25 +0200182 continue;
183 }
184 dokumente.add(new Quelldokument(datei, kopf, TextBereiniger.bereinige(rohText)));
185 }
186 return dokumente;
187 }
188
189 /**
190 * Wendet jeden Änderungsantrag auf den Entwurf an, den er ändern will, und nimmt ihn aus der
191 * Liste: Was danach bleibt, sind lauter Dokumente, die unmittelbar das Stammgesetz ändern.
192 *
193 * <p>Zugeordnet wird über die Drucksachennummer, die der Antrag selbst nennt („(Drs. 19/9707)“).
194 * Nur wo die fehlt, entscheidet die Reihenfolge der Argumente — der zuletzt genannte Entwurf
195 * davor. Findet sich gar kein Entwurf, bleibt der Antrag unangewandt und wird gemeldet; ihn
196 * ersatzweise auf das Stammgesetz loszulassen wäre falsch, denn seine Stellenangaben zielen auf
197 * die Drucksache.
198 */
199 private static List<Quelldokument> wendeAntraegeAn(
200 List<Quelldokument> dokumente, List<String> warnungen) {
201 if (dokumente.stream().noneMatch(d -> d.kopf().art() == DokumentArt.AENDERUNGSANTRAG)) {
202 return dokumente;
203 }
204 var ergebnis = new ArrayList<>(dokumente);
205 for (var antrag : dokumente) {
206 if (antrag.kopf().art() != DokumentArt.AENDERUNGSANTRAG) {
207 continue;
208 }
209 ergebnis.remove(antrag);
210 int zielIndex = findeEntwurf(ergebnis, antrag, dokumente.indexOf(antrag));
211 if (zielIndex < 0) {
212 warnungen.add(
213 ("%s ist ein Änderungsantrag zu %s; der zugehörige Gesetzentwurf wurde nicht"
214 + " mitgegeben, der Antrag blieb daher unberücksichtigt.")
215 .formatted(
Matthias Andreas Benkard2b1841e2026-08-15 10:51:11 +0200216 antrag.quelle().name(),
Matthias Andreas Benkard7235b342026-08-14 18:58:25 +0200217 antrag.kopf().bezugsDrucksachen().isEmpty()
218 ? "einer Drucksache"
219 : "Drs. " + String.join(", ", antrag.kopf().bezugsDrucksachen())));
220 continue;
221 }
222 var ziel = ergebnis.get(zielIndex);
223 var parseErgebnis = AenderungsantragParser.parse(antrag.text());
224 warnungen.addAll(parseErgebnis.warnungen());
225 var patch = EntwurfsPatcher.wendeAn(ziel.text(), parseErgebnis.befehle());
226 warnungen.addAll(patch.warnungen());
227 log.infof(
228 "%s: %d von %d Antragsbefehlen auf %s angewandt.",
Matthias Andreas Benkard2b1841e2026-08-15 10:51:11 +0200229 antrag.quelle().name(),
Matthias Andreas Benkard7235b342026-08-14 18:58:25 +0200230 patch.angewandt(),
231 parseErgebnis.befehle().size(),
Matthias Andreas Benkard2b1841e2026-08-15 10:51:11 +0200232 ziel.quelle().name());
Matthias Andreas Benkard7235b342026-08-14 18:58:25 +0200233 var eingearbeitet = new ArrayList<>(ziel.eingearbeitet());
Matthias Andreas Benkard2b1841e2026-08-15 10:51:11 +0200234 eingearbeitet.add(antrag.quelle().name() + " [" + antrag.kopf().anzeigeName() + "]");
Matthias Andreas Benkard7235b342026-08-14 18:58:25 +0200235 ergebnis.set(
236 zielIndex,
Matthias Andreas Benkardffc553b2026-08-15 12:18:32 +0200237 new Quelldokument(
238 ziel.quelle(),
239 ziel.kopf(),
240 patch.text(),
241 List.copyOf(eingearbeitet),
242 ziel.fassung()));
Matthias Andreas Benkard7235b342026-08-14 18:58:25 +0200243 }
244 return ergebnis;
245 }
246
247 /** Der Index des Entwurfs, den {@code antrag} ändert; {@code -1}, wenn keiner dabei ist. */
248 private static int findeEntwurf(
249 List<Quelldokument> dokumente, Quelldokument antrag, int antragsPosition) {
250 for (int i = 0; i < dokumente.size(); i++) {
251 var kopf = dokumente.get(i).kopf();
252 if (kopf.eigeneDrucksache() != null
253 && antrag.kopf().bezugsDrucksachen().contains(kopf.eigeneDrucksache())) {
254 return i;
255 }
256 }
257 int letzter = -1;
258 for (int i = 0; i < dokumente.size() && i < antragsPosition; i++) {
259 if (dokumente.get(i).kopf().art() == DokumentArt.GESETZENTWURF) {
260 letzter = i;
261 }
262 }
263 return letzter;
264 }
265
266 /**
267 * Entwürfe tragen hinter dem Regelungstext einen Begründungsteil, dessen Freitext keine Befehle
268 * enthält und den letzten Artikel nicht verunreinigen darf. Verkündete Gesetze haben ihn nicht —
269 * dort bliebe die Suche nach Begründungsmarken folgenlos, aber sie unterbleibt trotzdem, damit
270 * ein Gesetzblatt nicht an einem gleichlautenden Wort abbricht.
271 */
272 private static boolean entwurfsGrenzen(Quelldokument dokument) {
273 return dokument.kopf().art().istEntwurfsfassung();
274 }
275
Matthias Andreas Benkard2b1841e2026-08-15 10:51:11 +0200276 /** Bequemlichkeit für Befehlszeile und Tests; im Browser gibt es keine {@link Path}e. */
Matthias Andreas Benkard7b455cd2026-08-09 08:33:37 +0200277 static Gesetz ladeStammgesetz(Path baseFile) throws Exception {
Matthias Andreas Benkard2b1841e2026-08-15 10:51:11 +0200278 return ladeStammgesetz(Quelle.lies(baseFile));
279 }
280
Matthias Andreas Benkard06da70e2026-08-16 07:34:12 +0200281 /**
282 * Gii-XML → {@link GiiXmlLoader}; PDF/Klartext (Landesrecht) → {@link LandesRechtLoader}.
283 *
284 * <p>Vorweg wird ausgepackt: gesetze-im-internet.de gibt das Norm-XML nur als {@code xml.zip}
285 * aus, und das soll unentpackt taugen (siehe {@link ZipAuspacker}). Die Änderungsdokumente
286 * bleiben davon unberührt — Gesetzblätter und Drucksachen kommen nirgends als Archiv.
287 */
Matthias Andreas Benkard2b1841e2026-08-15 10:51:11 +0200288 static Gesetz ladeStammgesetz(Quelle baseFile) throws Exception {
Matthias Andreas Benkard06da70e2026-08-16 07:34:12 +0200289 var quelle = ZipAuspacker.auspacken(baseFile);
290 return DateiTyp.erkenne(quelle.inhalt()) == DateiTyp.XML
291 ? new GiiXmlLoader().load(quelle)
292 : new LandesRechtLoader().load(quelle);
Matthias Andreas Benkard7b455cd2026-08-09 08:33:37 +0200293 }
294
295 /**
296 * Der Superskriptmodus folgt der Schreibweise des Stammgesetzes: Trägt es amtliche Satznummern
297 * (bayerisches Landesrecht, Niedersachsen u.a.), werden auch die Änderungsgesetze mit
298 * Superskript-Erhalt extrahiert, damit Zitate und Stammtext dieselbe Schreibweise tragen; sonst
Matthias Andreas Benkard7235b342026-08-14 18:58:25 +0200299 * (Bundesrecht, Länder ohne amtliche Satzzählung) sind hochgestellte Ziffern bloße Fußnotenmarker
300 * und werden verworfen.
Matthias Andreas Benkard7b455cd2026-08-09 08:33:37 +0200301 */
302 static SuperskriptModus superskriptModus(Gesetz gesetz) {
303 for (var norm : gesetz.normen()) {
304 for (var absatz : norm.absaetze()) {
305 if (Superskript.traegtSatznummern(absatz.text())) {
306 return SuperskriptModus.BEHALTEN;
307 }
308 }
309 }
310 return SuperskriptModus.ENTFERNEN;
311 }
Matthias Andreas Benkard7b455cd2026-08-09 08:33:37 +0200312}