From e486b1f4efb034ed83247f4d4a84964ef9887221 Mon Sep 17 00:00:00 2001 From: Matthias Andreas Benkard Date: Mon, 13 Jul 2026 21:33:47 +0200 Subject: Model and apply structural (Teil/Abschnitt) and TOC commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce the Gliederungsbaum into the model and support the structural command families that were the bulk of the remaining unknowns: - Gliederung gains a kennzahl; Gesetz carries the ordered list of Gliederungseinheiten, which the loader now collects. - New Stelle components Gliederungseinheit (Teil/Abschnitt/Unterabschnitt/ Anlage/…) and Absatzbezeichnung; StellenParser parses these plus "Überschrift von " and drops "Satzteil/Angabe vor Nummer N" chapeau qualifiers. - Gliederungs-Überschrift Neufassung/Streichung apply to the tree and render as a "Geänderte Gliederungs-Überschriften" diff section; Absatzbezeichnung-Streichung removes an Absatz number; Inhaltsübersicht "Angabe(n) zu …" commands are recognized (applied via the existing TOC path). Reuses Neufassung/Aufhebung with structural Stellen rather than adding new command types. Final UnbekannterBefehl counts: GEG 50->5, IfSG 11->3, AGG 2->0 (§ 1 Alters->Lebensalters now applies), UWG 2->0. Co-Authored-By: Claude Opus 4.8 Change-Id: I0cc91bfe65798140dd982b19f1884dfe60be87c4 --- .../java/eu/mulk/aendggner/aenderung/Stelle.java | 40 +++++++- .../aendggner/aenderung/parse/BefehlErkenner.java | 44 +++++++++ .../aendggner/aenderung/parse/StellenParser.java | 36 +++++++ .../mulk/aendggner/anwendung/BefehlAnwender.java | 103 +++++++++++++++++++-- src/main/java/eu/mulk/aendggner/gesetz/Gesetz.java | 19 +++- .../java/eu/mulk/aendggner/gesetz/Gliederung.java | 20 +++- .../eu/mulk/aendggner/gesetz/gii/GiiXmlLoader.java | 7 +- .../eu/mulk/aendggner/synopse/HtmlRenderer.java | 18 ++++ .../java/eu/mulk/aendggner/synopse/Synopse.java | 5 + .../eu/mulk/aendggner/synopse/SynopseBuilder.java | 16 +++- src/test/java/eu/mulk/aendggner/EndToEndTest.java | 8 ++ .../aenderung/parse/BefehlErkennerTest.java | 37 ++++++++ .../aenderung/parse/StellenParserTest.java | 21 +++++ .../mulk/aendggner/synopse/HtmlRendererTest.java | 28 ++++++ 14 files changed, 387 insertions(+), 15 deletions(-) diff --git a/src/main/java/eu/mulk/aendggner/aenderung/Stelle.java b/src/main/java/eu/mulk/aendggner/aenderung/Stelle.java index 0bcceb6..eb135fe 100644 --- a/src/main/java/eu/mulk/aendggner/aenderung/Stelle.java +++ b/src/main/java/eu/mulk/aendggner/aenderung/Stelle.java @@ -17,11 +17,29 @@ public record Stelle(List komponenten) { } public sealed interface Komponente - permits Paragraph, AbsatzNr, SatzNr, NummerNr, BuchstabeNr, Ueberschrift, Inhaltsuebersicht {} + permits Paragraph, + AbsatzNr, + SatzNr, + NummerNr, + BuchstabeNr, + Ueberschrift, + Inhaltsuebersicht, + Gliederungseinheit, + Absatzbezeichnung {} /** „§ 5“, „§ 28a“ — die Paragraphennummer ohne „§ “. */ public record Paragraph(String nummer) implements Komponente {} + /** Eine Gliederungseinheit oberhalb des Paragraphen: „Teil 2“, „Abschnitt 3“, „Anlage 8“. */ + public record Gliederungseinheit(String art, String nummer) implements Komponente { + public String bezeichnung() { + return nummer.isEmpty() ? art : art + " " + nummer; + } + } + + /** „Die Absatzbezeichnung „(2)““ — die reine Absatznummer als Ziel einer Streichung. */ + public record Absatzbezeichnung(String nummer) implements Komponente {} + /** „Absatz 2“ */ public record AbsatzNr(String nummer) implements Komponente {} @@ -75,6 +93,24 @@ public record Stelle(List komponenten) { return komponenten.stream().anyMatch(Ueberschrift.class::isInstance); } + public List gliederungsPfad() { + return komponenten.stream() + .filter(Gliederungseinheit.class::isInstance) + .map(Gliederungseinheit.class::cast) + .toList(); + } + + public boolean betrifftGliederung() { + return komponenten.stream().anyMatch(Gliederungseinheit.class::isInstance); + } + + public Optional absatzbezeichnung() { + return komponenten.stream() + .filter(Absatzbezeichnung.class::isInstance) + .map(Absatzbezeichnung.class::cast) + .findFirst(); + } + public String anzeigeText() { if (komponenten.isEmpty()) { return "(gesamtes Gesetz)"; @@ -93,6 +129,8 @@ public record Stelle(List komponenten) { case BuchstabeNr b -> "Buchstabe " + b.kennung(); case Ueberschrift u -> "Überschrift"; case Inhaltsuebersicht i -> "Inhaltsübersicht"; + case Gliederungseinheit g -> g.bezeichnung(); + case Absatzbezeichnung a -> "Absatzbezeichnung (" + a.nummer() + ")"; }); } return sb.toString(); diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java index 80f664e..7283d90 100644 --- a/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java +++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java @@ -128,6 +128,26 @@ final class BefehlErkenner { private static final Pattern AUFHEBUNG = Pattern.compile("^(.+?) (?:wird|werden) aufgehoben\\.$"); + // „Die Überschrift von Teil 3 Abschnitt 2 wird gestrichen.“ — Streichung einer Gliederungs- + // Überschrift (die Wörter-Streichung STREICHUNG erfordert dagegen ein Zitat). + private static final Pattern UEBERSCHRIFT_STREICHUNG = + Pattern.compile("^(Die Überschrift (?:von|des|der|zu) .+?) wird gestrichen\\.$"); + + // „Die Absatzbezeichnung „(2)“ wird gestrichen.“ + private static final Pattern ABSATZBEZEICHNUNG_STREICHUNG = + Pattern.compile("^Die Absatzbezeichnung " + Z + " wird gestrichen\\.$"); + + // Inhaltsübersicht: „Die Angabe(n) zu <…> wird/werden wie folgt gefasst / durch … ersetzt / + // gestrichen.“ Wird als Änderung der Inhaltsübersicht typisiert (Anwendung erfolgt gesondert). + private static final Pattern INHALTSUEBERSICHT_ANGABE = + Pattern.compile( + "^Die Angaben? (?:zu|zur) .+? (?:wird|werden) " + + "(?:wie folgt gefasst: " + + Z + + "|durch (?:die )?folgende[nrs]? Angaben? ersetzt: " + + Z + + "|gestrichen)\\.?$"); + private static final Pattern STREICHUNG = Pattern.compile( "^(?:In )?(.+?) (?:wird|werden) (?:jeweils )?" + WOERTER + " " + Z + " gestrichen\\.$"); @@ -284,6 +304,19 @@ final class BefehlErkenner { Matcher m; + // Inhaltsübersichts-Angaben zuerst prüfen, bevor NEUFASSUNG/STRUKTUR_ERSETZUNG die Phrase + // strukturell (aber mit unparsbarer Stelle) an sich ziehen. + if ((m = INHALTSUEBERSICHT_ANGABE.matcher(text)).matches()) { + var stelle = kontext.plus(new Stelle(List.of(new Stelle.Inhaltsuebersicht()))); + if (m.group(1) != null) { + return Optional.of(new Neufassung(stelle, zitat(zitate, m.group(1)), provenienz)); + } + if (m.group(2) != null) { + return Optional.of(new Neufassung(stelle, zitat(zitate, m.group(2)), provenienz)); + } + return Optional.of(new Aufhebung(stelle, provenienz)); + } + if ((m = PARAGRAPH_BEREICH_NEUFASSUNG.matcher(text)).matches()) { return paragraphBereichNeufassung(zitat(zitate, m.group(3)), kontext, provenienz); } @@ -497,6 +530,17 @@ final class BefehlErkenner { return ausStellen(m.group(1), s -> new Aufhebung(kontext.plus(s), provenienz)); } + if ((m = UEBERSCHRIFT_STREICHUNG.matcher(text)).matches()) { + return StellenParser.parse(m.group(1)) + .map(s -> new Aufhebung(kontext.plus(s), provenienz)); + } + + if ((m = ABSATZBEZEICHNUNG_STREICHUNG.matcher(text)).matches()) { + var nummer = zitat(zitate, m.group(1)).replaceAll("[^0-9a-z]", ""); + var stelle = new Stelle(List.of(new Stelle.Absatzbezeichnung(nummer))); + return Optional.of(new Aufhebung(kontext.plus(stelle), provenienz)); + } + if ((m = STREICHUNG.matcher(text)).matches()) { var woerter = wortZitat(zitate, m.group(2)); return ausStellen(m.group(1), s -> new Streichung(kontext.plus(s), woerter, provenienz)); diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java index 2b2b80e..4660343 100644 --- a/src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java +++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java @@ -30,6 +30,9 @@ public final class StellenParser { "dem", "den", "des", + "von", + "zu", + "zur", "neue", "neuen", "bisherige", @@ -41,7 +44,15 @@ public final class StellenParser { private StellenParser() {} + // „in dem Satzteil vor Nummer 1“, „in der Angabe vor Nummer 1“ — verfeinernde Chapeau-Angaben + // ohne eigene Stelle-Komponente; die Ersetzung sucht ohnehin im Text der umgebenden Stelle. + private static final Pattern CHAPEAU_QUALIFIER = + Pattern.compile( + "(?i)(?:in dem |im |in der )?(?:Satzteil|Angabe) vor " + + "(?:Nummer|Buchstabe|Satz|Absatz) \\S+"); + public static Optional parse(String phrase) { + phrase = CHAPEAU_QUALIFIER.matcher(phrase).replaceAll(" ").strip(); var woerter = phrase.strip().split("\\s+"); var komponenten = new ArrayList(); @@ -91,6 +102,16 @@ public final class StellenParser { komponenten.add(new Stelle.BuchstabeNr(wert)); i++; } + case "Teil", "Teils", "Buch", "Buches", "Kapitel", "Kapitels", "Abschnitt", "Abschnitts", + "Unterabschnitt", "Unterabschnitts", "Anlage", "Anlagen" -> { + var wert = naechstesWort(woerter, i); + if (wert == null || !NUMMER_WERT.matcher(wert).matches()) { + return Optional.empty(); + } + komponenten.add(new Stelle.Gliederungseinheit(gliederungsArt(wort), wert)); + i++; + } + case "Anhang" -> komponenten.add(new Stelle.Gliederungseinheit("Anhang", "")); case "Inhaltsübersicht" -> komponenten.add(new Stelle.Inhaltsuebersicht()); case "Überschrift" -> komponenten.add(new Stelle.Ueberschrift()); default -> { @@ -251,8 +272,10 @@ public final class StellenParser { case Stelle.SatzNr s -> new Stelle.SatzNr(label); case Stelle.NummerNr n -> new Stelle.NummerNr(label); case Stelle.BuchstabeNr b -> new Stelle.BuchstabeNr(label); + case Stelle.Gliederungseinheit g -> new Stelle.Gliederungseinheit(g.art(), label); case Stelle.Ueberschrift u -> null; case Stelle.Inhaltsuebersicht i -> null; + case Stelle.Absatzbezeichnung a -> null; }; } @@ -305,6 +328,19 @@ public final class StellenParser { return Optional.of(new Stelle(komponenten)); } + /** Normalisiert Genitiv-/Pluralformen der Gliederungsart auf den Nominativ Singular. */ + private static String gliederungsArt(String wort) { + return switch (wort) { + case "Teils" -> "Teil"; + case "Buches" -> "Buch"; + case "Kapitels" -> "Kapitel"; + case "Abschnitts" -> "Abschnitt"; + case "Unterabschnitts" -> "Unterabschnitt"; + case "Anlagen" -> "Anlage"; + default -> wort; + }; + } + private static String naechstesWort(String[] woerter, int i) { return i + 1 < woerter.length ? entfernePunktuation(woerter[i + 1]) : null; } diff --git a/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java index 94c5db1..86eccaf 100644 --- a/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java +++ b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java @@ -17,6 +17,7 @@ import eu.mulk.aendggner.aenderung.Aenderungsbefehl.WortlautZuAbsatz; import eu.mulk.aendggner.aenderung.Stelle; import eu.mulk.aendggner.gesetz.Absatz; import eu.mulk.aendggner.gesetz.Gesetz; +import eu.mulk.aendggner.gesetz.Gliederung; import eu.mulk.aendggner.gesetz.Norm; import java.util.ArrayList; import java.util.LinkedHashSet; @@ -60,16 +61,19 @@ public final class BefehlAnwender { public static AnwendungsErgebnis anwenden(Gesetz alt, List befehle) { var normen = new ArrayList<>(alt.normen()); + var gliederungen = new ArrayList<>(alt.gliederungen()); var protokoll = new ArrayList(); for (var befehl : befehle) { - protokoll.add(wendeAn(normen, befehl)); + protokoll.add(wendeAn(normen, gliederungen, befehl)); } - return new AnwendungsErgebnis(alt.mitNormen(normen), protokoll); + return new AnwendungsErgebnis( + alt.mitNormen(normen).mitGliederungen(gliederungen), protokoll); } - private static AngewandteAenderung wendeAn(List normen, Aenderungsbefehl befehl) { + private static AngewandteAenderung wendeAn( + List normen, List gliederungen, Aenderungsbefehl befehl) { if (befehl instanceof UnbekannterBefehl) { return manuell(befehl, "Befehl nicht erkannt."); } @@ -78,6 +82,14 @@ public final class BefehlAnwender { befehl, "Änderungen an der Inhaltsübersicht werden nicht automatisch angewandt."); } try { + // Änderungen an Gliederungs-Überschriften (Teil/Abschnitt/…) wirken auf den Gliederungsbaum. + if (befehl.stelle().betrifftGliederung()) { + return switch (befehl) { + case Neufassung n -> wendeGliederungNeufassungAn(gliederungen, n); + case Aufhebung a -> wendeGliederungStreichungAn(gliederungen, a); + default -> manuell(befehl, "Strukturänderung wird nicht automatisch angewandt."); + }; + } return switch (befehl) { case Ersetzung e -> wendeErsetzungAn(normen, e); case Streichung s -> wendeStreichungAn(normen, s); @@ -89,7 +101,7 @@ public final class BefehlAnwender { case Aufhebung a -> wendeAufhebungAn(normen, a); case Umnummerierung u -> wendeUmnummerierungAn(normen, u); case WortlautZuAbsatz w -> wendeWortlautZuAbsatzAn(normen, w); - case Sammelbefehl s -> wendeSammelAn(normen, s); + case Sammelbefehl s -> wendeSammelAn(normen, gliederungen, s); case UnbekannterBefehl u -> manuell(befehl, "Befehl nicht erkannt."); }; } catch (RuntimeException e) { @@ -97,6 +109,66 @@ public final class BefehlAnwender { } } + // --- Gliederungs-Überschriften ------------------------------------------------------------- + + private static AngewandteAenderung wendeGliederungNeufassungAn( + List gliederungen, Neufassung befehl) { + int idx = findeGliederung(gliederungen, befehl.stelle().gliederungsPfad()); + if (idx < 0) { + return manuell(befehl, "Gliederungseinheit nicht gefunden."); + } + var alt = gliederungen.get(idx); + var titel = befehl.neuerText().replaceAll("\\s+", " ").strip(); + if (titel.startsWith(alt.bezeichnung())) { + titel = titel.substring(alt.bezeichnung().length()).strip(); + } + gliederungen.set(idx, alt.mitTitel(titel.isEmpty() ? null : titel)); + return angewandt(befehl, alt.bezeichnung()); + } + + private static AngewandteAenderung wendeGliederungStreichungAn( + List gliederungen, Aufhebung befehl) { + int idx = findeGliederung(gliederungen, befehl.stelle().gliederungsPfad()); + if (idx < 0) { + return manuell(befehl, "Gliederungseinheit nicht gefunden."); + } + var alt = gliederungen.get(idx); + gliederungen.set(idx, alt.mitTitel("(weggefallen)")); + return angewandt(befehl, alt.bezeichnung()); + } + + /** + * Findet die Gliederungseinheit zum Pfad („Teil 3 Abschnitt 2“): jede Ebene wird per Bezeichnung + * innerhalb des Kennzahl-Präfixes der übergeordneten Ebene aufgelöst. + */ + private static int findeGliederung( + List gliederungen, List pfad) { + if (pfad.isEmpty()) { + return -1; + } + String praefix = ""; + int gefunden = -1; + for (var einheit : pfad) { + gefunden = -1; + for (int i = 0; i < gliederungen.size(); i++) { + var g = gliederungen.get(i); + if (g.bezeichnung().equals(einheit.bezeichnung()) + && (g.kennzahl() == null || g.kennzahl().startsWith(praefix))) { + gefunden = i; + break; + } + } + if (gefunden < 0) { + return -1; + } + var kennzahl = gliederungen.get(gefunden).kennzahl(); + if (kennzahl != null) { + praefix = kennzahl; + } + } + return gefunden; + } + // --- Wortweise Textoperationen ------------------------------------------------------------- private static AngewandteAenderung wendeErsetzungAn(List normen, Ersetzung befehl) { @@ -401,6 +473,24 @@ public final class BefehlAnwender { private static AngewandteAenderung wendeAufhebungAn(List normen, Aufhebung befehl) { var stelle = befehl.stelle(); + if (stelle.absatzbezeichnung().isPresent()) { + var aufloesung = loeseNormAuf(normen, stelle); + if (aufloesung.fehler() != null) { + return manuell(befehl, aufloesung.fehler()); + } + var norm = normen.get(aufloesung.normIndex()); + var nummer = stelle.absatzbezeichnung().get().nummer(); + var absaetze = new ArrayList<>(norm.absaetze()); + for (int i = 0; i < absaetze.size(); i++) { + if (nummer.equals(absaetze.get(i).nummer())) { + absaetze.set(i, new Absatz(null, absaetze.get(i).text())); + normen.set(aufloesung.normIndex(), norm.mitAbsaetzen(absaetze)); + return angewandt(befehl, norm.enbez()); + } + } + return manuell(befehl, "Absatz (" + nummer + ") nicht gefunden."); + } + if (nurParagraph(stelle)) { var aufloesung = loeseNormAuf(normen, stelle); if (aufloesung.fehler() != null) { @@ -497,12 +587,13 @@ public final class BefehlAnwender { * zusammen. Nur wenn alle Teile gelingen, gilt der Befehl als angewandt; sonst wird er zur * manuellen Prüfung markiert (bereits angewandte Teile bleiben wirksam). */ - private static AngewandteAenderung wendeSammelAn(List normen, Sammelbefehl befehl) { + private static AngewandteAenderung wendeSammelAn( + List normen, List gliederungen, Sammelbefehl befehl) { var betroffene = new LinkedHashSet(); var fehler = new ArrayList(); int i = 1; for (var teil : befehl.teilbefehle()) { - var ergebnis = wendeAn(normen, teil); + var ergebnis = wendeAn(normen, gliederungen, teil); betroffene.addAll(ergebnis.betroffeneEnbez()); if (ergebnis.status() != Status.ANGEWANDT) { fehler.add("Teil " + i + " (" + teil.stelle().anzeigeText() + "): " + ergebnis.begruendung()); diff --git a/src/main/java/eu/mulk/aendggner/gesetz/Gesetz.java b/src/main/java/eu/mulk/aendggner/gesetz/Gesetz.java index 5db0860..fc84172 100644 --- a/src/main/java/eu/mulk/aendggner/gesetz/Gesetz.java +++ b/src/main/java/eu/mulk/aendggner/gesetz/Gesetz.java @@ -3,11 +3,20 @@ package eu.mulk.aendggner.gesetz; import java.util.List; import java.util.Optional; -/** Ein Stammgesetz, bestehend aus einer geordneten Liste von Einzelnormen. */ -public record Gesetz(String jurabk, String langue, String kurzue, List normen) { +/** + * Ein Stammgesetz, bestehend aus einer geordneten Liste von Einzelnormen und den (ebenfalls + * geordneten) Gliederungseinheiten (Teil/Abschnitt/Unterabschnitt/…), die die Normen strukturieren. + */ +public record Gesetz( + String jurabk, String langue, String kurzue, List normen, List gliederungen) { public Gesetz { normen = List.copyOf(normen); + gliederungen = List.copyOf(gliederungen); + } + + public Gesetz(String jurabk, String langue, String kurzue, List normen) { + this(jurabk, langue, kurzue, normen, List.of()); } public Optional norm(String enbez) { @@ -15,6 +24,10 @@ public record Gesetz(String jurabk, String langue, String kurzue, List nor } public Gesetz mitNormen(List neueNormen) { - return new Gesetz(jurabk, langue, kurzue, neueNormen); + return new Gesetz(jurabk, langue, kurzue, neueNormen, gliederungen); + } + + public Gesetz mitGliederungen(List neueGliederungen) { + return new Gesetz(jurabk, langue, kurzue, normen, neueGliederungen); } } diff --git a/src/main/java/eu/mulk/aendggner/gesetz/Gliederung.java b/src/main/java/eu/mulk/aendggner/gesetz/Gliederung.java index 21c9c4a..53eb6b9 100644 --- a/src/main/java/eu/mulk/aendggner/gesetz/Gliederung.java +++ b/src/main/java/eu/mulk/aendggner/gesetz/Gliederung.java @@ -2,10 +2,26 @@ package eu.mulk.aendggner.gesetz; import org.jspecify.annotations.Nullable; -/** Eine Gliederungseinheit (z.B. „2. Abschnitt — Koordinierung und Früherkennung“). */ -public record Gliederung(String bezeichnung, @Nullable String titel) { +/** + * Eine Gliederungseinheit (z.B. „Teil 2 — Anforderungen an zu errichtende Gebäude“ oder „2. + * Abschnitt — Koordinierung und Früherkennung“). + * + * @param kennzahl die hierarchie-kodierende Gliederungskennzahl aus dem gii-XML (z.B. {@code 020020} + * für Abschnitt 2 in Teil 2); {@code null} bei anderweitig konstruierten Einheiten. + * @param bezeichnung die relative Bezeichnung („Teil 2“, „Abschnitt 2“). + * @param titel die Überschrift der Einheit ({@code null} bei titellosen Einheiten). + */ +public record Gliederung(@Nullable String kennzahl, String bezeichnung, @Nullable String titel) { + + public Gliederung(String bezeichnung, @Nullable String titel) { + this(null, bezeichnung, titel); + } public String anzeigeText() { return titel == null ? bezeichnung : bezeichnung + " — " + titel; } + + public Gliederung mitTitel(@Nullable String neuerTitel) { + return new Gliederung(kennzahl, bezeichnung, neuerTitel); + } } diff --git a/src/main/java/eu/mulk/aendggner/gesetz/gii/GiiXmlLoader.java b/src/main/java/eu/mulk/aendggner/gesetz/gii/GiiXmlLoader.java index ca7bdf6..b14ec1c 100644 --- a/src/main/java/eu/mulk/aendggner/gesetz/gii/GiiXmlLoader.java +++ b/src/main/java/eu/mulk/aendggner/gesetz/gii/GiiXmlLoader.java @@ -39,6 +39,7 @@ public final class GiiXmlLoader { String langue = null; String kurzue = null; var normen = new ArrayList(); + var gliederungen = new ArrayList(); Gliederung aktuelleGliederung = null; for (var normElement : kindElemente(wurzel, "norm")) { @@ -55,10 +56,12 @@ public final class GiiXmlLoader { var gliederungselement = erstesKind(metadaten, "gliederungseinheit"); if (gliederungselement != null) { + var kennzahl = kindText(gliederungselement, "gliederungskennzahl"); var bez = kindText(gliederungselement, "gliederungsbez"); var titel = kindText(gliederungselement, "gliederungstitel"); if (bez != null) { - aktuelleGliederung = new Gliederung(bez, titel); + aktuelleGliederung = new Gliederung(kennzahl, bez, titel); + gliederungen.add(aktuelleGliederung); } } @@ -79,7 +82,7 @@ public final class GiiXmlLoader { if (jurabk == null) { throw new SAXException("Keine -Elemente mit Metadaten gefunden: " + datei); } - return new Gesetz(jurabk, langue, kurzue, normen); + return new Gesetz(jurabk, langue, kurzue, normen, gliederungen); } private static ArrayList leseAbsaetze(Element normElement) { diff --git a/src/main/java/eu/mulk/aendggner/synopse/HtmlRenderer.java b/src/main/java/eu/mulk/aendggner/synopse/HtmlRenderer.java index 2f5f735..46e1fa8 100644 --- a/src/main/java/eu/mulk/aendggner/synopse/HtmlRenderer.java +++ b/src/main/java/eu/mulk/aendggner/synopse/HtmlRenderer.java @@ -25,6 +25,8 @@ public final class HtmlRenderer { rendereKopf(sb, synopse, quelleBeschreibung); + rendereGliederungsAenderungen(sb, synopse); + for (var eintrag : synopse.eintraege()) { rendereEintrag(sb, eintrag); } @@ -58,6 +60,22 @@ public final class HtmlRenderer { sb.append("\n"); } + private static void rendereGliederungsAenderungen(StringBuilder sb, Synopse synopse) { + if (synopse.gliederungsAenderungen().isEmpty()) { + return; + } + sb.append("
\n

Geänderte Gliederungs-Überschriften

\n"); + for (var aenderung : synopse.gliederungsAenderungen()) { + var spalten = WortDiff.vergleiche(aenderung.alt().anzeigeText(), aenderung.neu().anzeigeText()); + sb.append("
\n
") + .append(spalten.altHtml()) + .append("
\n
") + .append(spalten.neuHtml()) + .append("
\n
\n"); + } + sb.append("
\n"); + } + private static void rendereEintrag(StringBuilder sb, Synopse.Eintrag eintrag) { sb.append("
eintraege, + List gliederungsAenderungen, List manuellZuPruefen, List warnungen) { + /** Eine geänderte Gliederungs-Überschrift (Teil/Abschnitt/…). */ + public record GliederungsAenderung(Gliederung alt, Gliederung neu) {} + public enum Aenderungsart { UNVERAENDERT, GEAENDERT, diff --git a/src/main/java/eu/mulk/aendggner/synopse/SynopseBuilder.java b/src/main/java/eu/mulk/aendggner/synopse/SynopseBuilder.java index 0f88538..536bca4 100644 --- a/src/main/java/eu/mulk/aendggner/synopse/SynopseBuilder.java +++ b/src/main/java/eu/mulk/aendggner/synopse/SynopseBuilder.java @@ -50,7 +50,21 @@ public final class SynopseBuilder { var manuell = anwendung.protokoll().stream().filter(a -> a.status() == Status.MANUELL_PRUEFEN).toList(); - return new Synopse(alt, neu, eintraege, manuell, parseWarnungen); + return new Synopse(alt, neu, eintraege, gliederungsAenderungen(alt, neu), manuell, parseWarnungen); + } + + /** Paart die Gliederungseinheiten nach Kennzahl und sammelt die mit geänderter Überschrift. */ + private static List gliederungsAenderungen(Gesetz alt, Gesetz neu) { + var aenderungen = new ArrayList(); + for (var neuG : neu.gliederungen()) { + alt.gliederungen().stream() + .filter(a -> java.util.Objects.equals(a.kennzahl(), neuG.kennzahl())) + .filter(a -> a.kennzahl() != null) + .findFirst() + .filter(a -> !java.util.Objects.equals(a.titel(), neuG.titel())) + .ifPresent(a -> aenderungen.add(new Synopse.GliederungsAenderung(a, neuG))); + } + return aenderungen; } private static boolean gleicherInhalt( diff --git a/src/test/java/eu/mulk/aendggner/EndToEndTest.java b/src/test/java/eu/mulk/aendggner/EndToEndTest.java index bd17d79..44903e7 100644 --- a/src/test/java/eu/mulk/aendggner/EndToEndTest.java +++ b/src/test/java/eu/mulk/aendggner/EndToEndTest.java @@ -110,10 +110,18 @@ class EndToEndTest { assertThat(parseErgebnis.artikel()).contains("1"); assertThat(parseErgebnis.befehle().size()).isGreaterThanOrEqualTo(100); + // Der Großteil der Befehle wird erkannt; nur einige Spezialfälle (Mehrquell-Ersetzungen, + // §§-Bereiche, Chapeau-Angaben) bleiben unbekannt. + var unbekannt = + parseErgebnis.befehle().stream().filter(b -> b instanceof UnbekannterBefehl).count(); + assertThat(unbekannt).isLessThanOrEqualTo(8); + // Das XML ist bereits konsolidiert; entscheidend ist, dass die Anwendung sauber terminiert // und jeden Befehl protokolliert. var anwendung = BefehlAnwender.anwenden(gesetz, parseErgebnis.befehle()); assertThat(anwendung.protokoll()).hasSameSizeAs(parseErgebnis.befehle()); + // Gliederungs-Überschriften (Teil/Abschnitt) werden als Befehle erkannt und angewandt. + assertThat(anwendung.anzahlAngewandt()).isGreaterThanOrEqualTo(50); var synopse = SynopseBuilder.baue(gesetz, anwendung, parseErgebnis.warnungen(), false); assertThat(HtmlRenderer.rendere(synopse, "E2E-Test")).contains("Manuell prüfen"); } diff --git a/src/test/java/eu/mulk/aendggner/aenderung/parse/BefehlErkennerTest.java b/src/test/java/eu/mulk/aendggner/aenderung/parse/BefehlErkennerTest.java index 2d0db8a..230bfca 100644 --- a/src/test/java/eu/mulk/aendggner/aenderung/parse/BefehlErkennerTest.java +++ b/src/test/java/eu/mulk/aendggner/aenderung/parse/BefehlErkennerTest.java @@ -518,6 +518,43 @@ class BefehlErkennerTest { assertThat(e.amEnde()).isTrue(); } + @Test + void gliederungsUeberschriftNeufassung() { + var befehl = + erkenne( + "Die Überschrift von Teil 3 wird wie folgt gefasst: „Teil 3 Anforderungen an" + + " bestehende Gebäude“.", + Stelle.LEER); + assertThat(befehl).get().isInstanceOf(Neufassung.class); + assertThat(befehl.orElseThrow().stelle().betrifftGliederung()).isTrue(); + } + + @Test + void gliederungsUeberschriftStreichung() { + var befehl = erkenne("Die Überschrift von Teil 2 Abschnitt 4 wird gestrichen.", Stelle.LEER); + assertThat(befehl).get().isInstanceOf(Aufhebung.class); + assertThat(befehl.orElseThrow().stelle().gliederungsPfad()).hasSize(2); + } + + @Test + void absatzbezeichnungStreichung() { + var befehl = + erkenne( + "Die Absatzbezeichnung „(2)“ wird gestrichen.", + new Stelle(List.of(new Stelle.Paragraph("64")))); + var b = befehl.orElseThrow(); + assertThat(b).isInstanceOf(Aufhebung.class); + assertThat(b.stelle().absatzbezeichnung()).get().extracting(Stelle.Absatzbezeichnung::nummer) + .isEqualTo("2"); + } + + @Test + void inhaltsuebersichtAngabeWirdTypisiert() { + var befehl = erkenne("Die Angabe zu Teil 3 wird wie folgt gefasst: „Teil 3 Neu“.", Stelle.LEER); + assertThat(befehl).get().isInstanceOf(Neufassung.class); + assertThat(befehl.orElseThrow().stelle().betrifftInhaltsuebersicht()).isTrue(); + } + @Test void mehrfachErsetzungWirdSammelbefehl() { // Mehrere Ersetzungspaare unter einem gemeinsamen „ersetzt“. diff --git a/src/test/java/eu/mulk/aendggner/aenderung/parse/StellenParserTest.java b/src/test/java/eu/mulk/aendggner/aenderung/parse/StellenParserTest.java index c0ea159..9389534 100644 --- a/src/test/java/eu/mulk/aendggner/aenderung/parse/StellenParserTest.java +++ b/src/test/java/eu/mulk/aendggner/aenderung/parse/StellenParserTest.java @@ -67,4 +67,25 @@ class StellenParserTest { assertThat(stellen).extracting(Stelle::anzeigeText) .containsExactly("Absatz 1 Satz 1", "Absatz 1 Satz 2"); } + + @Test + void parstGliederungspfad() { + assertThat(StellenParser.parse("Teil 3 Abschnitt 2").orElseThrow().anzeigeText()) + .isEqualTo("Teil 3 Abschnitt 2"); + } + + @Test + void parstUeberschriftVonGliederung() { + var stelle = StellenParser.parse("Die Überschrift von Teil 3 Abschnitt 2").orElseThrow(); + assertThat(stelle.betrifftUeberschrift()).isTrue(); + assertThat(stelle.gliederungsPfad()).hasSize(2); + assertThat(stelle.anzeigeText()).isEqualTo("Überschrift Teil 3 Abschnitt 2"); + } + + @Test + void ignoriertChapeauZusatz() { + // „in der Angabe vor Nummer 1“ ist ein verfeinernder Zusatz ohne eigene Komponente. + assertThat(StellenParser.parse("Absatz 1 in der Angabe vor Nummer 1").orElseThrow().anzeigeText()) + .isEqualTo("Absatz 1"); + } } diff --git a/src/test/java/eu/mulk/aendggner/synopse/HtmlRendererTest.java b/src/test/java/eu/mulk/aendggner/synopse/HtmlRendererTest.java index b82778c..01c25cd 100644 --- a/src/test/java/eu/mulk/aendggner/synopse/HtmlRendererTest.java +++ b/src/test/java/eu/mulk/aendggner/synopse/HtmlRendererTest.java @@ -3,12 +3,14 @@ package eu.mulk.aendggner.synopse; import static org.assertj.core.api.Assertions.assertThat; import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Ersetzung; +import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Neufassung; import eu.mulk.aendggner.aenderung.Aenderungsbefehl.UnbekannterBefehl; import eu.mulk.aendggner.aenderung.Provenienz; import eu.mulk.aendggner.aenderung.Stelle; import eu.mulk.aendggner.anwendung.BefehlAnwender; import eu.mulk.aendggner.gesetz.Absatz; import eu.mulk.aendggner.gesetz.Gesetz; +import eu.mulk.aendggner.gesetz.Gliederung; import eu.mulk.aendggner.gesetz.Norm; import java.util.List; import org.junit.jupiter.api.Test; @@ -70,6 +72,32 @@ class HtmlRendererTest { assertThat(html).contains("Die Nummern 1 bis 3 werden aufgehoben."); } + @Test + void wendetGliederungsUeberschriftAnUndRendertDiff() { + var alt = + new Gesetz( + "TestG", + "Testgesetz", + "TestG", + List.of(new Norm("§ 1", "Zweck", null, List.of(new Absatz("1", "Text.")), false)), + List.of(new Gliederung("030", "Teil 3", "Alte Überschrift"))); + var befehl = + new Neufassung( + new Stelle( + List.of(new Stelle.Ueberschrift(), new Stelle.Gliederungseinheit("Teil", "3"))), + "Teil 3 Neue Überschrift", + PROV); + + var anwendung = BefehlAnwender.anwenden(alt, List.of(befehl)); + assertThat(anwendung.protokoll().get(0).status()).isEqualTo(BefehlAnwender.Status.ANGEWANDT); + assertThat(anwendung.neu().gliederungen().get(0).titel()).isEqualTo("Neue Überschrift"); + + var html = + HtmlRenderer.rendere(SynopseBuilder.baue(alt, anwendung, List.of(), false), "quelle"); + assertThat(html).contains("Geänderte Gliederungs-Überschriften"); + assertThat(html).contains("Alte").contains("Neue"); + } + @Test void zeigtWarnungenAn() { var html = HtmlRenderer.rendere(synopse(), "quelle"); -- cgit v1.2.1