Model and apply structural (Teil/Abschnitt) and TOC commands
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 <Gliederung>" 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 <noreply@anthropic.com>
Change-Id: I0cc91bfe65798140dd982b19f1884dfe60be87c4
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 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 @@
return komponenten.stream().anyMatch(Ueberschrift.class::isInstance);
}
+ public List<Gliederungseinheit> 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> 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 @@
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 @@
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 @@
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 @@
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 @@
"dem",
"den",
"des",
+ "von",
+ "zu",
+ "zur",
"neue",
"neuen",
"bisherige",
@@ -41,7 +44,15 @@
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<Stelle> parse(String phrase) {
+ phrase = CHAPEAU_QUALIFIER.matcher(phrase).replaceAll(" ").strip();
var woerter = phrase.strip().split("\\s+");
var komponenten = new ArrayList<Stelle.Komponente>();
@@ -91,6 +102,16 @@
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 @@
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 @@
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.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 static AnwendungsErgebnis anwenden(Gesetz alt, List<Aenderungsbefehl> befehle) {
var normen = new ArrayList<>(alt.normen());
+ var gliederungen = new ArrayList<>(alt.gliederungen());
var protokoll = new ArrayList<AngewandteAenderung>();
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<Norm> normen, Aenderungsbefehl befehl) {
+ private static AngewandteAenderung wendeAn(
+ List<Norm> normen, List<Gliederung> gliederungen, Aenderungsbefehl befehl) {
if (befehl instanceof UnbekannterBefehl) {
return manuell(befehl, "Befehl nicht erkannt.");
}
@@ -78,6 +82,14 @@
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 @@
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 @@
}
}
+ // --- Gliederungs-Überschriften -------------------------------------------------------------
+
+ private static AngewandteAenderung wendeGliederungNeufassungAn(
+ List<Gliederung> 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<Gliederung> 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<Gliederung> gliederungen, List<Stelle.Gliederungseinheit> 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<Norm> normen, Ersetzung befehl) {
@@ -401,6 +473,24 @@
private static AngewandteAenderung wendeAufhebungAn(List<Norm> 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 @@
* 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<Norm> normen, Sammelbefehl befehl) {
+ private static AngewandteAenderung wendeSammelAn(
+ List<Norm> normen, List<Gliederung> gliederungen, Sammelbefehl befehl) {
var betroffene = new LinkedHashSet<String>();
var fehler = new ArrayList<String>();
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 @@
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<Norm> 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<Norm> normen, List<Gliederung> gliederungen) {
public Gesetz {
normen = List.copyOf(normen);
+ gliederungen = List.copyOf(gliederungen);
+ }
+
+ public Gesetz(String jurabk, String langue, String kurzue, List<Norm> normen) {
+ this(jurabk, langue, kurzue, normen, List.of());
}
public Optional<Norm> norm(String enbez) {
@@ -15,6 +24,10 @@
}
public Gesetz mitNormen(List<Norm> neueNormen) {
- return new Gesetz(jurabk, langue, kurzue, neueNormen);
+ return new Gesetz(jurabk, langue, kurzue, neueNormen, gliederungen);
+ }
+
+ public Gesetz mitGliederungen(List<Gliederung> 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 @@
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 @@
String langue = null;
String kurzue = null;
var normen = new ArrayList<Norm>();
+ var gliederungen = new ArrayList<Gliederung>();
Gliederung aktuelleGliederung = null;
for (var normElement : kindElemente(wurzel, "norm")) {
@@ -55,10 +56,12 @@
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 @@
if (jurabk == null) {
throw new SAXException("Keine <norm>-Elemente mit Metadaten gefunden: " + datei);
}
- return new Gesetz(jurabk, langue, kurzue, normen);
+ return new Gesetz(jurabk, langue, kurzue, normen, gliederungen);
}
private static ArrayList<Absatz> 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 @@
rendereKopf(sb, synopse, quelleBeschreibung);
+ rendereGliederungsAenderungen(sb, synopse);
+
for (var eintrag : synopse.eintraege()) {
rendereEintrag(sb, eintrag);
}
@@ -58,6 +60,22 @@
sb.append("</header>\n");
}
+ private static void rendereGliederungsAenderungen(StringBuilder sb, Synopse synopse) {
+ if (synopse.gliederungsAenderungen().isEmpty()) {
+ return;
+ }
+ sb.append("<section class=\"gliederung-aenderungen\">\n<h2>Geänderte Gliederungs-Überschriften</h2>\n");
+ for (var aenderung : synopse.gliederungsAenderungen()) {
+ var spalten = WortDiff.vergleiche(aenderung.alt().anzeigeText(), aenderung.neu().anzeigeText());
+ sb.append("<div class=\"vergleich\">\n<div class=\"alt\">")
+ .append(spalten.altHtml())
+ .append("</div>\n<div class=\"neu\">")
+ .append(spalten.neuHtml())
+ .append("</div>\n</div>\n");
+ }
+ sb.append("</section>\n");
+ }
+
private static void rendereEintrag(StringBuilder sb, Synopse.Eintrag eintrag) {
sb.append("<section class=\"norm ")
.append(eintrag.art().name().toLowerCase())
diff --git a/src/main/java/eu/mulk/aendggner/synopse/Synopse.java b/src/main/java/eu/mulk/aendggner/synopse/Synopse.java
index 26730a2..4f7a58f 100644
--- a/src/main/java/eu/mulk/aendggner/synopse/Synopse.java
+++ b/src/main/java/eu/mulk/aendggner/synopse/Synopse.java
@@ -2,6 +2,7 @@
import eu.mulk.aendggner.anwendung.BefehlAnwender.AngewandteAenderung;
import eu.mulk.aendggner.gesetz.Gesetz;
+import eu.mulk.aendggner.gesetz.Gliederung;
import eu.mulk.aendggner.gesetz.Norm;
import java.util.List;
import org.jspecify.annotations.Nullable;
@@ -11,9 +12,13 @@
Gesetz alt,
Gesetz neu,
List<Eintrag> eintraege,
+ List<GliederungsAenderung> gliederungsAenderungen,
List<AngewandteAenderung> manuellZuPruefen,
List<String> 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 @@
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<Synopse.GliederungsAenderung> gliederungsAenderungen(Gesetz alt, Gesetz neu) {
+ var aenderungen = new ArrayList<Synopse.GliederungsAenderung>();
+ 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 @@
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
@@ -519,6 +519,43 @@
}
@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“.
var befehl =
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 @@
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 @@
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;
@@ -71,6 +73,32 @@
}
@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("<del>Alte</del>").contains("<ins>Neue</ins>");
+ }
+
+ @Test
void zeigtWarnungenAn() {
var html = HtmlRenderer.rendere(synopse(), "quelle");