blob: 16df2756049373af3a1dc2570b61b6b6d3ac4d0f [file] [log] [blame]
package eu.mulk.aendggner;
import eu.mulk.aendggner.aenderung.DokumentArt;
import eu.mulk.aendggner.aenderung.DokumentKopf;
import eu.mulk.aendggner.aenderung.parse.AenderungsantragParser;
import eu.mulk.aendggner.aenderung.parse.AenderungsgesetzParser;
import eu.mulk.aendggner.aenderung.parse.DokumentErkenner;
import eu.mulk.aendggner.aenderung.parse.EntwurfsPatcher;
import eu.mulk.aendggner.aenderung.parse.PatchTextExtraktor;
import eu.mulk.aendggner.aenderung.parse.SuperskriptModus;
import eu.mulk.aendggner.aenderung.parse.TextBereiniger;
import eu.mulk.aendggner.anwendung.BefehlAnwender;
import eu.mulk.aendggner.gesetz.Gesetz;
import eu.mulk.aendggner.gesetz.Superskript;
import eu.mulk.aendggner.gesetz.gii.GiiXmlLoader;
import eu.mulk.aendggner.gesetz.land.LandesRechtLoader;
import eu.mulk.aendggner.synopse.HtmlRenderer;
import eu.mulk.aendggner.synopse.SynopseBuilder;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import org.jspecify.annotations.Nullable;
/**
* Kernpipeline: Stammgesetz laden → Änderungsgesetze parsen und anwenden → Synopse rendern.
*
* <p>Wird sowohl von der Befehlszeile ({@link AendGgner}) als auch von der Browserfassung ({@code
* eu.mulk.aendggner.wasm.BrowserMain}) verwendet, damit die Anwendungslogik nur an einer Stelle
* existiert. Sie kennt kein Dateisystem: Eingaben kommen als {@link Quelle} (Name und Bytes).
*/
public final class Pipeline {
private static final org.jboss.logging.Logger log =
org.jboss.logging.Logger.getLogger(Pipeline.class);
private Pipeline() {}
public record Ergebnis(
String html,
long anzahlAngewandt,
long anzahlManuell,
int anzahlGeaenderteNormen,
int anzahlProtokollEintraege) {}
/**
* Ein eingespeistes Änderungsdokument samt erkannter Art und aufbereitetem Text.
*
* @param eingearbeitet die Dokumente, die vor der Anwendung in {@code text} eingearbeitet wurden
* — bei einem Entwurf die Änderungsanträge, die ihn geändert haben. Sie gehen in die
* Quellenzeile ein, denn die gezeigte Fassung ist ohne sie nicht nachvollziehbar.
* @param fassung welche von mehreren Fassungen des Dokuments gilt, sofern es mehrere trägt — die
* Beschlussempfehlung stellt Entwurf und Ausschussfassung nebeneinander. Sonst {@code null}.
*/
record Quelldokument(
Quelle quelle,
DokumentKopf kopf,
String text,
List<String> eingearbeitet,
@Nullable String fassung) {
Quelldokument(Quelle quelle, DokumentKopf kopf, String text) {
this(quelle, kopf, text, List.of(), null);
}
Quelldokument(Quelle quelle, DokumentKopf kopf, String text, List<String> eingearbeitet) {
this(quelle, kopf, text, eingearbeitet, null);
}
String quellenAngabe(List<String> artikel) {
var sb = new StringBuilder(quelle.name());
sb.append(" [").append(kopf.anzeigeName());
if (fassung != null) {
sb.append(", ").append(fassung);
}
sb.append("]");
for (var zusatz : eingearbeitet) {
sb.append(" + ").append(zusatz);
}
return sb.append(" (Artikel ").append(String.join(", ", artikel)).append(")").toString();
}
}
/** Bequemlichkeit für Befehlszeile und Tests; im Browser gibt es keine {@link Path}e. */
public static Ergebnis erzeugeSynopse(
Path baseFile, List<Path> patches, String artikel, boolean vollstaendig) throws Exception {
var patchQuellen = new ArrayList<Quelle>();
for (var patch : patches) {
patchQuellen.add(Quelle.lies(patch));
}
return erzeugeSynopse(Quelle.lies(baseFile), patchQuellen, artikel, vollstaendig);
}
public static Ergebnis erzeugeSynopse(
Quelle baseFile, List<Quelle> patches, String artikel, boolean vollstaendig)
throws Exception {
var altesGesetz = ladeStammgesetz(baseFile);
var extraktor = new PatchTextExtraktor(superskriptModus(altesGesetz));
var parser = new AenderungsgesetzParser();
var warnungen = new ArrayList<String>();
var dokumente = wendeAntraegeAn(leseDokumente(patches, extraktor, warnungen), warnungen);
var gesetz = altesGesetz;
var protokoll = new ArrayList<BefehlAnwender.AngewandteAenderung>();
var quellen = new ArrayList<String>();
boolean entwurfsfassung = false;
for (var dokument : dokumente) {
var parseErgebnis = parser.parse(dokument.text(), gesetz, artikel, entwurfsGrenzen(dokument));
if (parseErgebnis.befehle().isEmpty()) {
warnungen.add(
"In %s (%s) wurde kein auf %s anwendbarer Artikel gefunden."
.formatted(
dokument.quelle().name(), dokument.kopf().anzeigeName(), gesetz.jurabk()));
}
var anwendung = BefehlAnwender.anwenden(gesetz, parseErgebnis.befehle());
gesetz = anwendung.neu();
protokoll.addAll(anwendung.protokoll());
warnungen.addAll(parseErgebnis.warnungen());
entwurfsfassung |= dokument.kopf().art().istEntwurfsfassung();
quellen.add(dokument.quellenAngabe(parseErgebnis.artikel()));
}
var gesamtErgebnis = new BefehlAnwender.AnwendungsErgebnis(gesetz, protokoll);
var synopse = SynopseBuilder.baue(altesGesetz, gesamtErgebnis, warnungen, vollstaendig);
var quellenZeile = baseFile.name() + " + " + String.join(" + ", quellen);
var html = HtmlRenderer.rendere(synopse, quellenZeile, entwurfsfassung);
return new Ergebnis(
html,
gesamtErgebnis.anzahlAngewandt(),
gesamtErgebnis.anzahlManuell(),
synopse.eintraege().size(),
protokoll.size());
}
/**
* Liest die Änderungsdokumente ein, bestimmt ihre Art und sortiert die aus, aus denen keine
* Synopse zu gewinnen ist. Verworfen wird nichts stillschweigend: Jedes ausgesonderte Dokument
* hinterlässt eine Warnung, die in der Synopse erscheint.
*/
private static List<Quelldokument> leseDokumente(
List<Quelle> patches, PatchTextExtraktor extraktor, List<String> warnungen) throws Exception {
var dokumente = new ArrayList<Quelldokument>();
for (var datei : patches) {
var rohText = extraktor.extrahiere(datei);
// Die Erkennung arbeitet auf dem Rohtext: Der Bereiniger entfernt genau die
// Drucksachenköpfe, aus denen Art und Nummer hervorgehen.
var kopf = DokumentErkenner.erkenne(rohText);
log.infof("Datei %s erkannt als %s.", datei.name(), kopf.anzeigeName());
if (kopf.art() == DokumentArt.OHNE_BEFEHLE) {
warnungen.add(
"%s ist ein %s und enthält keine Änderungsbefehle; die Datei wurde übergangen."
.formatted(datei.name(), kopf.art().anzeigeName()));
continue;
}
if (kopf.art() == DokumentArt.BESCHLUSSEMPFEHLUNG) {
// Die maßgebliche Fassung steht nicht im Fließtext, sondern in der zweispaltigen
// Zusammenstellung: links der Entwurf, rechts die Beschlüsse des Ausschusses.
var fassung = extraktor.leseZusammenstellung(datei);
warnungen.addAll(fassung.warnungen());
if (fassung.text() == null) {
// Eine halb aufgelöste Fassung auszugeben wäre schlimmer als keine.
warnungen.add(
("%s ist eine Beschlussempfehlung, deren Zusammenstellung sich nicht auflösen ließ;"
+ " die Datei wurde übergangen. Für eine Synopse eignet sich der zugrunde"
+ " liegende Gesetzentwurf%s.")
.formatted(
datei.name(),
kopf.bezugsDrucksachen().isEmpty()
? ""
: " (Drs. " + kopf.bezugsDrucksachen().get(0) + ")"));
continue;
}
dokumente.add(
new Quelldokument(
datei,
kopf,
TextBereiniger.bereinige(fassung.text()),
List.of(),
"Ausschussfassung"));
continue;
}
dokumente.add(new Quelldokument(datei, kopf, TextBereiniger.bereinige(rohText)));
}
return dokumente;
}
/**
* Wendet jeden Änderungsantrag auf den Entwurf an, den er ändern will, und nimmt ihn aus der
* Liste: Was danach bleibt, sind lauter Dokumente, die unmittelbar das Stammgesetz ändern.
*
* <p>Zugeordnet wird über die Drucksachennummer, die der Antrag selbst nennt („(Drs. 19/9707)“).
* Nur wo die fehlt, entscheidet die Reihenfolge der Argumente — der zuletzt genannte Entwurf
* davor. Findet sich gar kein Entwurf, bleibt der Antrag unangewandt und wird gemeldet; ihn
* ersatzweise auf das Stammgesetz loszulassen wäre falsch, denn seine Stellenangaben zielen auf
* die Drucksache.
*/
private static List<Quelldokument> wendeAntraegeAn(
List<Quelldokument> dokumente, List<String> warnungen) {
if (dokumente.stream().noneMatch(d -> d.kopf().art() == DokumentArt.AENDERUNGSANTRAG)) {
return dokumente;
}
var ergebnis = new ArrayList<>(dokumente);
for (var antrag : dokumente) {
if (antrag.kopf().art() != DokumentArt.AENDERUNGSANTRAG) {
continue;
}
ergebnis.remove(antrag);
int zielIndex = findeEntwurf(ergebnis, antrag, dokumente.indexOf(antrag));
if (zielIndex < 0) {
warnungen.add(
("%s ist ein Änderungsantrag zu %s; der zugehörige Gesetzentwurf wurde nicht"
+ " mitgegeben, der Antrag blieb daher unberücksichtigt.")
.formatted(
antrag.quelle().name(),
antrag.kopf().bezugsDrucksachen().isEmpty()
? "einer Drucksache"
: "Drs. " + String.join(", ", antrag.kopf().bezugsDrucksachen())));
continue;
}
var ziel = ergebnis.get(zielIndex);
var parseErgebnis = AenderungsantragParser.parse(antrag.text());
warnungen.addAll(parseErgebnis.warnungen());
var patch = EntwurfsPatcher.wendeAn(ziel.text(), parseErgebnis.befehle());
warnungen.addAll(patch.warnungen());
log.infof(
"%s: %d von %d Antragsbefehlen auf %s angewandt.",
antrag.quelle().name(),
patch.angewandt(),
parseErgebnis.befehle().size(),
ziel.quelle().name());
var eingearbeitet = new ArrayList<>(ziel.eingearbeitet());
eingearbeitet.add(antrag.quelle().name() + " [" + antrag.kopf().anzeigeName() + "]");
ergebnis.set(
zielIndex,
new Quelldokument(
ziel.quelle(),
ziel.kopf(),
patch.text(),
List.copyOf(eingearbeitet),
ziel.fassung()));
}
return ergebnis;
}
/** Der Index des Entwurfs, den {@code antrag} ändert; {@code -1}, wenn keiner dabei ist. */
private static int findeEntwurf(
List<Quelldokument> dokumente, Quelldokument antrag, int antragsPosition) {
for (int i = 0; i < dokumente.size(); i++) {
var kopf = dokumente.get(i).kopf();
if (kopf.eigeneDrucksache() != null
&& antrag.kopf().bezugsDrucksachen().contains(kopf.eigeneDrucksache())) {
return i;
}
}
int letzter = -1;
for (int i = 0; i < dokumente.size() && i < antragsPosition; i++) {
if (dokumente.get(i).kopf().art() == DokumentArt.GESETZENTWURF) {
letzter = i;
}
}
return letzter;
}
/**
* Entwürfe tragen hinter dem Regelungstext einen Begründungsteil, dessen Freitext keine Befehle
* enthält und den letzten Artikel nicht verunreinigen darf. Verkündete Gesetze haben ihn nicht —
* dort bliebe die Suche nach Begründungsmarken folgenlos, aber sie unterbleibt trotzdem, damit
* ein Gesetzblatt nicht an einem gleichlautenden Wort abbricht.
*/
private static boolean entwurfsGrenzen(Quelldokument dokument) {
return dokument.kopf().art().istEntwurfsfassung();
}
/** Bequemlichkeit für Befehlszeile und Tests; im Browser gibt es keine {@link Path}e. */
static Gesetz ladeStammgesetz(Path baseFile) throws Exception {
return ladeStammgesetz(Quelle.lies(baseFile));
}
/**
* Gii-XML → {@link GiiXmlLoader}; PDF/Klartext (Landesrecht) → {@link LandesRechtLoader}.
*
* <p>Vorweg wird ausgepackt: gesetze-im-internet.de gibt das Norm-XML nur als {@code xml.zip}
* aus, und das soll unentpackt taugen (siehe {@link ZipAuspacker}). Die Änderungsdokumente
* bleiben davon unberührt — Gesetzblätter und Drucksachen kommen nirgends als Archiv.
*/
static Gesetz ladeStammgesetz(Quelle baseFile) throws Exception {
var quelle = ZipAuspacker.auspacken(baseFile);
return DateiTyp.erkenne(quelle.inhalt()) == DateiTyp.XML
? new GiiXmlLoader().load(quelle)
: new LandesRechtLoader().load(quelle);
}
/**
* Der Superskriptmodus folgt der Schreibweise des Stammgesetzes: Trägt es amtliche Satznummern
* (bayerisches Landesrecht, Niedersachsen u.a.), werden auch die Änderungsgesetze mit
* Superskript-Erhalt extrahiert, damit Zitate und Stammtext dieselbe Schreibweise tragen; sonst
* (Bundesrecht, Länder ohne amtliche Satzzählung) sind hochgestellte Ziffern bloße Fußnotenmarker
* und werden verworfen.
*/
static SuperskriptModus superskriptModus(Gesetz gesetz) {
for (var norm : gesetz.normen()) {
for (var absatz : norm.absaetze()) {
if (Superskript.traegtSatznummern(absatz.text())) {
return SuperskriptModus.BEHALTEN;
}
}
}
return SuperskriptModus.ENTFERNEN;
}
}