From 5f30f566873c313b554f087c0f7e132efa54d64a Mon Sep 17 00:00:00 2001 From: Matthias Andreas Benkard Date: Tue, 28 Jul 2026 06:00:16 +0200 Subject: Recognise word-anchored insertions; close quotes at the next item MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The documented next step was the word-anchored structural insertion ("Vor den Wörtern „Aus dem Bereich Verkehr:“ wird folgender Absatz 5 eingefügt", Berlin Artikel 1 Nr. 2 b) bb)). StrukturEinfuegung now carries an optional WortAnker, the recogniser has a pattern for the form — placed before STRUKTUR_EINFUEGUNG, which bails out of recognition entirely when the StellenParser cannot read "den Wörtern «0»" — and the applier positions the new unit at the anchor line instead of a structural boundary. Two blockers turned up next to it, both measured on the corpus rather than assumed: The command was not cleanly recognisable at all. Its closing quotation mark is missing in the official text, so the quote swallowed items cc) and c); an anchor alone would have made the command look applicable with a huge foreign quote as its payload. The docs called a boundary at enumeration markers impossible, and that holds for the unguarded form: a quoted amendment provision carries command language itself. Guarded by four conditions together — the article's quotation marks demonstrably do not balance, the next line's marker is at the same or a shallower level than the one the quote opened on, that line carries command language, and closing here leaves the rest of the article balanced — it fires exactly twice in the whole sample corpus, both times where the missing mark belongs: Berlin before cc), and GV. NRW. Artikel 2 before 12. Without the guards it also fires inside quoted amendment provisions in the BayJG and GModG documents; the balance gate rules those out. The GV.-NRW. gazette encodes part of its umlauts decomposed (u + U+0308, 79 places, dozens of them "eingefügt:"/"angefügt:"). Invisible in the text, but a different word to every command pattern, so those commands could never match. TextBereiniger now normalises to NFC first — not NFKC, which would flatten the official sentence numbers ¹²³ and take SatzTeiler and Superskript their basis. Every other sample document, every gii-XML stem and every hand-kept plain-text stem is already NFC, so the pinned figures stay bit-identical. Berlin Artikel 1 is thereby fully recognised (6 of 6 commands, none unknown). One pinned statement changed for a stated reason: the NRW acceptance test asserted the article-heading warning, which is now preempted by the item boundary inside Artikel 2 — Nr. 12 survives instead of being swallowed. 241 tests green. Co-Authored-By: Claude Opus 5 Change-Id: Ic579bec6fecc2495a61b3dcf87c9ad71b42f34ae --- src/test/java/eu/mulk/aendggner/EndToEndTest.java | 48 +++++++++--- .../aenderung/parse/BefehlErkennerTest.java | 25 +++++++ .../aenderung/parse/TextBereinigerTest.java | 19 +++++ .../aenderung/parse/ZitatExtraktorTest.java | 62 ++++++++++++++++ .../aendggner/anwendung/BefehlAnwenderTest.java | 85 ++++++++++++++++++++++ 5 files changed, 227 insertions(+), 12 deletions(-) (limited to 'src/test/java/eu') diff --git a/src/test/java/eu/mulk/aendggner/EndToEndTest.java b/src/test/java/eu/mulk/aendggner/EndToEndTest.java index b3bea08..c4ba894 100644 --- a/src/test/java/eu/mulk/aendggner/EndToEndTest.java +++ b/src/test/java/eu/mulk/aendggner/EndToEndTest.java @@ -4,10 +4,13 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assumptions.assumeTrue; import eu.mulk.aendggner.aenderung.Aenderungsbefehl; +import eu.mulk.aendggner.aenderung.Aenderungsbefehl.StrukturEinfuegung; import eu.mulk.aendggner.aenderung.Aenderungsbefehl.UnbekannterBefehl; +import eu.mulk.aendggner.aenderung.Aenderungsbefehl.WortAnker; import eu.mulk.aendggner.aenderung.parse.AenderungsgesetzParser; import eu.mulk.aendggner.aenderung.parse.PatchTextExtraktor; import eu.mulk.aendggner.aenderung.parse.TextBereiniger; +import eu.mulk.aendggner.aenderung.parse.ZitatExtraktor; import eu.mulk.aendggner.anwendung.BefehlAnwender; import eu.mulk.aendggner.gesetz.Gesetz; import eu.mulk.aendggner.gesetz.gii.GiiXmlLoader; @@ -517,10 +520,12 @@ class EndToEndTest { assertThat(parseErgebnis.befehle()).hasSize(4); assertThat(parseErgebnis.befehle()).noneMatch(b -> b instanceof UnbekannterBefehl); - // Im amtlichen Satz fehlt bei Artikel 2 Nr. 11 das schließende Anführungszeichen. Ohne die - // Artikel-Grenze im ZitatExtraktor verschlänge dieses Zitat die Artikel 3 bis 5. + // Im amtlichen Satz fehlt bei Artikel 2 Nr. 11 das schließende Anführungszeichen. Ohne eine + // Strukturgrenze im ZitatExtraktor verschlänge dieses Zitat die Artikel 3 bis 5. Die Grenze + // greift seit der Aufzählungs-Grenze schon am nächsten Punkt desselben Artikels („12.“) und + // nicht mehr erst an der Überschrift „Artikel 3“ — Artikel 2 Nr. 12 bleibt damit erhalten. assertThat(parseErgebnis.warnungen()) - .anyMatch(w -> w.startsWith("Zitat vor einer Artikel-Überschrift nicht geschlossen")); + .anyMatch(w -> w.startsWith("Zitat vor dem Aufzählungspunkt „12.“ nicht geschlossen")); var anwendung = BefehlAnwender.anwenden(gesetz, parseErgebnis.befehle()); assertThat(anwendung.anzahlAngewandt()).isEqualTo(4); @@ -562,8 +567,10 @@ class EndToEndTest { * *

Kein voller Akzeptanztest: gesetze.berlin.de ist wie das schleswig-holsteinische Portal eine * anmeldepflichtige juris-Anwendung, die Stammfassungen sind daraus nicht zu beschaffen. Artikel 1 - * ändert zudem eine *Anlage* mit eigener Nummern-Gliederung — ein Änderungsziel, das ÄndGgner - * nicht modelliert (dieselbe Grenze, an der Baden-Württemberg zurückgestellt wurde). + * ändert zudem eine *Anlage*; anlagenbezogene Befehle wendet ÄndGgner an (siehe GEG), doch der + * {@link eu.mulk.aendggner.gesetz.land.LandesRechtTextParser} kennt nur „§“- und + * „Art.“-Normköpfe — eine handgepflegte Stammfassung könnte diese Anlage nicht tragen (dieselbe + * Grenze, an der Baden-Württemberg zurückgestellt wurde). */ @Test void asogLafAendGBerlin() throws Exception { @@ -612,12 +619,29 @@ class EndToEndTest { "§ 67 Absatz 2", "Anlage Nummer 6 Absatz 2", "Anlage Nummer 23 Absatz 4a", - "Anlage Nummer 23"); - // Bekannte Grenze: „Vor den Wörtern „…“ wird folgender Absatz 5 eingefügt“ — eine - // Struktureinfügung, deren Position ein Wortanker statt einer Stelle bestimmt. - assertThat(artikel1.befehle().stream().filter(b -> b instanceof UnbekannterBefehl).toList()) - .singleElement() - .extracting(b -> b.provenienz().gliederungsPfad()) - .isEqualTo("2. b) bb)"); + "Anlage Nummer 23", + "Anlage Nummer 23 Absatz 9", + "Anlage Nummer 31"); + assertThat(artikel1.befehle()).noneMatch(b -> b instanceof UnbekannterBefehl); + + // Nr. 2 b) bb) ist die Struktureinfügung mit Wortanker: „Vor den Wörtern „Aus dem Bereich + // Verkehr:“ wird folgender Absatz 5 eingefügt“ — die Position bestimmt der Wortanker, das Ziel + // erbt der Befehl aus dem Rahmen („Nummer 23“ der Anlage). + var bb = + artikel1.befehle().stream() + .filter(b -> b.provenienz().gliederungsPfad().equals("2. b) bb)")) + .findFirst() + .orElseThrow(); + assertThat(bb).isInstanceOf(StrukturEinfuegung.class); + var einfuegung = (StrukturEinfuegung) bb; + assertThat(einfuegung.vorher()).isTrue(); + assertThat(einfuegung.bezeichnung()).isEqualTo("5"); + assertThat(einfuegung.anker()) + .isEqualTo(new WortAnker.VorWoertern("Aus dem Bereich Verkehr:")); + + // Bei diesem Punkt fehlt im amtlichen Satz das schließende Anführungszeichen. Ohne die Grenze + // am nächsten Aufzählungspunkt verschlänge das offene Zitat die Punkte cc) und c). + assertThat(ZitatExtraktor.extrahiere(text).warnungen()) + .anyMatch(w -> w.startsWith("Zitat vor dem Aufzählungspunkt „cc)“ nicht geschlossen")); } } 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 5213030..95d15e1 100644 --- a/src/test/java/eu/mulk/aendggner/aenderung/parse/BefehlErkennerTest.java +++ b/src/test/java/eu/mulk/aendggner/aenderung/parse/BefehlErkennerTest.java @@ -161,6 +161,31 @@ class BefehlErkennerTest { assertThat(einfuegung.bezeichnung()).isNull(); } + @Test + void erkenntStrukturEinfuegungMitWortanker() { + // Echter Befehl aus Artikel 1 Nr. 2 b) bb) des ASOG-/LAF-Änderungsgesetzes (GVBl. für Berlin + // 17/2026): Die Position der neuen Einheit bestimmt ein Wortanker, das Ziel erbt der Befehl aus + // dem Rahmen („Nummer 23“ der Anlage). + var kontext = + new Stelle( + List.of(new Stelle.Gliederungseinheit("Anlage", ""), new Stelle.NummerNr("23"))); + var befehl = + erkenne( + "Vor den Wörtern „Aus dem Bereich Verkehr:“ wird folgender Absatz 5 eingefügt:" + + " „(5) die Identifizierung unerlaubt eingereister Ausländerinnen und Ausländer.“", + kontext); + + assertThat(befehl).containsInstanceOf(StrukturEinfuegung.class); + var einfuegung = (StrukturEinfuegung) befehl.orElseThrow(); + assertThat(einfuegung.stelle().anzeigeText()).isEqualTo("Anlage Nummer 23"); + assertThat(einfuegung.vorher()).isTrue(); + assertThat(einfuegung.ebene()).isEqualTo(Ebene.ABSATZ); + assertThat(einfuegung.bezeichnung()).isEqualTo("5"); + assertThat(einfuegung.anker()) + .isEqualTo(new WortAnker.VorWoertern("Aus dem Bereich Verkehr:")); + assertThat(einfuegung.text()).startsWith("(5) die Identifizierung"); + } + @Test void erkenntWoerterEinfuegung() { var befehl = diff --git a/src/test/java/eu/mulk/aendggner/aenderung/parse/TextBereinigerTest.java b/src/test/java/eu/mulk/aendggner/aenderung/parse/TextBereinigerTest.java index 5b7e850..284c506 100644 --- a/src/test/java/eu/mulk/aendggner/aenderung/parse/TextBereinigerTest.java +++ b/src/test/java/eu/mulk/aendggner/aenderung/parse/TextBereinigerTest.java @@ -19,6 +19,25 @@ class TextBereinigerTest { assertThat(TextBereiniger.bereinige(roh)).isEqualTo("Erster Satz.\nZweiter Satz."); } + @Test + void setztZerlegteUmlauteKanonischZusammen() { + // Das GV.-NRW.-Heft 7/2026 kodiert einen Teil seiner Umlaute zerlegt; für die Befehlsmuster wäre + // „angefügt“ dann ein anderes Wort und dutzende Befehle könnten nie matchen. + // Der Umlaut steht hier bewusst zerlegt (u + U+0308), wie im Heft. + var roh = "Dem § 128 wird folgender Absatz 3 angefu\u0308gt:"; + + assertThat(TextBereiniger.bereinige(roh)) + .isEqualTo("Dem § 128 wird folgender Absatz 3 angefügt:"); + } + + @Test + void erhaeltAmtlicheSatznummernBeiDerNormalisierung() { + // NFC, nicht NFKC: Letzteres plättete die amtlichen Satznummern zu gewöhnlichen Ziffern und + // nähme SatzTeiler und Superskript ihre Grundlage. + assertThat(TextBereiniger.bereinige("¹Erster Satz. ²Zweiter Satz.")) + .isEqualTo("¹Erster Satz. ²Zweiter Satz."); + } + @Test void ziehtSilbentrennungZusammen() { assertThat(TextBereiniger.bereinige("die Bundes-\nregierung")).isEqualTo("die Bundesregierung"); diff --git a/src/test/java/eu/mulk/aendggner/aenderung/parse/ZitatExtraktorTest.java b/src/test/java/eu/mulk/aendggner/aenderung/parse/ZitatExtraktorTest.java index 618d078..5b39857 100644 --- a/src/test/java/eu/mulk/aendggner/aenderung/parse/ZitatExtraktorTest.java +++ b/src/test/java/eu/mulk/aendggner/aenderung/parse/ZitatExtraktorTest.java @@ -93,6 +93,68 @@ class ZitatExtraktorTest { assertThat(ergebnis.warnungen()).isEmpty(); } + @Test + void schliesstOffenesZitatVorDemNaechstenAufzaehlungspunkt() { + // GVBl. für Berlin 17/2026 Artikel 1 Nr. 2 b) bb): Am Ende des neu gefassten Absatzes fehlt das + // schließende Anführungszeichen, das Zitat verschlänge sonst die Punkte cc) und c). + var ergebnis = + ZitatExtraktor.extrahiere( + "Artikel 1\n" + + "bb) Vor den Wörtern „Aus dem Bereich Verkehr:“ wird folgender Absatz 5" + + " eingefügt:\n" + + "„(5) Die Sicherheitskontrolle.\n" + + "cc) Die bisherigen Absätze 5 bis 9 werden die Absätze 6 bis 10.\n" + + "c) Nummer 31 wird wie folgt gefasst:\n" + + "„Nummer 31\nNeuer Text.“\n"); + + // Zitat 0 ist der Wortanker, Zitat 1 der unvollständig zitierte neue Absatz, Zitat 2 die + // Neufassung aus Punkt c) — dieser Punkt bleibt also erhalten. + assertThat(ergebnis.zitate()).hasSize(3); + assertThat(ergebnis.zitat(1)).isEqualTo("(5) Die Sicherheitskontrolle."); + assertThat(ergebnis.zitat(2)).isEqualTo("Nummer 31\nNeuer Text."); + assertThat(ergebnis.text()) + .contains("cc) Die bisherigen Absätze 5 bis 9 werden die Absätze 6 bis 10.") + .contains("c) Nummer 31 wird wie folgt gefasst:"); + assertThat(ergebnis.warnungen()).hasSize(1); + assertThat(ergebnis.warnungen().get(0)) + .startsWith("Zitat vor dem Aufzählungspunkt „cc)“ nicht geschlossen"); + } + + @Test + void raetNichtImAusbalanciertenAbschnitt() { + // Ein zitiertes Änderungsgesetz trägt selbst Befehlssprache in seinen Aufzählungspunkten (so in + // den bayerischen GVBl-Heften). Solange die Anführungszeichen des Artikels aufgehen, wird + // deshalb nicht geraten — das Zitat läuft über die Punkte hinweg. + var ergebnis = + ZitatExtraktor.extrahiere( + "Artikel 1\n" + + "2. § 5 wird wie folgt gefasst:\n" + + "„Die Verordnung wird wie folgt geändert:\n" + + "1. § 2 wird wie folgt geändert:\n" + + "2. § 3 wird aufgehoben.“\n"); + + assertThat(ergebnis.zitate()).hasSize(1); + assertThat(ergebnis.zitat(0)).contains("1. § 2 wird wie folgt geändert:"); + assertThat(ergebnis.warnungen()).isEmpty(); + } + + @Test + void schliesstNichtAnTieferemAufzaehlungspunkt() { + // Der Marker der Folgezeile muss auf derselben oder einer flacheren Ebene liegen als der Punkt, + // auf dem das Zitat aufging: eine zitierte Untergliederung setzt das Zitat nicht ab. + var ergebnis = + ZitatExtraktor.extrahiere( + "Artikel 1\n" + + "2. § 5 wird wie folgt gefasst:\n" + + "„(1) Es gilt:\n" + + "aa) § 7 wird aufgehoben.\n"); + + assertThat(ergebnis.zitate()).hasSize(1); + assertThat(ergebnis.zitat(0)).contains("aa) § 7 wird aufgehoben."); + assertThat(ergebnis.warnungen()).hasSize(1); + assertThat(ergebnis.warnungen().get(0)).contains("offen"); + } + @Test void stelltZitateWiederHer() { var original = "Das Wort „alt“ wird durch das Wort „neu“ ersetzt."; diff --git a/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java b/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java index a8f3daa..2706ac8 100644 --- a/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java +++ b/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java @@ -218,6 +218,91 @@ class BefehlAnwenderTest { assertThat(ergebnis.protokoll().get(0).begruendung()).contains("existiert bereits"); } + @Test + void fuegtEinheitVorWortankerEin() { + // „Vor den Wörtern „…“ wird folgende Nummer 1a eingefügt: „…““ — die Position bestimmt der + // Wortanker, nicht die Struktur (GVBl. für Berlin 17/2026, Artikel 1 Nr. 2 b) bb)). + var befehl = + new StrukturEinfuegung( + stelle(new Stelle.Paragraph("1"), new Stelle.AbsatzNr("2")), + true, + Ebene.NUMMER, + "1a", + "1a. die Prüfung von Zitaten,", + new WortAnker.VorWoertern("die Anwendung von Befehlen"), + PROV); + + var ergebnis = BefehlAnwender.anwenden(gesetz(), List.of(befehl)); + + assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.ANGEWANDT); + assertThat(absatzText(ergebnis.neu(), "§ 1", 1)) + .isEqualTo( + "Die Erprobung umfasst\n" + + " 1. das Einlesen von Gesetzen,\n" + + " 1a. die Prüfung von Zitaten,\n" + + " 2. die Anwendung von Befehlen und\n" + + " 3. die Ausgabe von Synopsen."); + } + + @Test + void fuegtEinheitNachWortankerEin() { + var befehl = + new StrukturEinfuegung( + stelle(new Stelle.Paragraph("1"), new Stelle.AbsatzNr("2")), + false, + Ebene.NUMMER, + "1a", + "1a. die Prüfung von Zitaten,", + new WortAnker.NachWoertern("das Einlesen von Gesetzen"), + PROV); + + var ergebnis = BefehlAnwender.anwenden(gesetz(), List.of(befehl)); + + assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.ANGEWANDT); + assertThat(absatzText(ergebnis.neu(), "§ 1", 1)) + .contains(" 1. das Einlesen von Gesetzen,\n 1a. die Prüfung von Zitaten,\n 2. die" + + " Anwendung von Befehlen und"); + } + + @Test + void meldetFehlendenWortankerEinerEinfuegung() { + var befehl = + new StrukturEinfuegung( + stelle(new Stelle.Paragraph("1"), new Stelle.AbsatzNr("2")), + true, + Ebene.NUMMER, + "1a", + "1a. die Prüfung von Zitaten,", + new WortAnker.VorWoertern("gibt es nicht"), + PROV); + + var ergebnis = BefehlAnwender.anwenden(gesetz(), List.of(befehl)); + + var eintrag = ergebnis.protokoll().get(0); + assertThat(eintrag.status()).isEqualTo(Status.MANUELL_PRUEFEN); + assertThat(eintrag.begruendung()).contains("kommt im Zieltext nicht vor"); + } + + @Test + void meldetMehrdeutigenWortankerEinerEinfuegung() { + // „Prüfung“ steht zweimal im Wortlaut des § 2 — die Position wäre nicht bestimmt. + var befehl = + new StrukturEinfuegung( + stelle(new Stelle.Paragraph("2")), + true, + Ebene.SATZ, + null, + "Die Prüfung ist zu dokumentieren.", + new WortAnker.VorWoertern("Prüfung"), + PROV); + + var ergebnis = BefehlAnwender.anwenden(gesetz(), List.of(befehl)); + + var eintrag = ergebnis.protokoll().get(0); + assertThat(eintrag.status()).isEqualTo(Status.MANUELL_PRUEFEN); + assertThat(eintrag.begruendung()).contains("mehrdeutig"); + } + @Test void fuegtWoerterNachAnkerEin() { var befehl = -- cgit v1.2.1