diff options
| author | Matthias Andreas Benkard <code@mail.matthias.benkard.de> | 2026-07-28 06:00:16 +0200 |
|---|---|---|
| committer | Matthias Andreas Benkard <code@mail.matthias.benkard.de> | 2026-07-28 06:00:16 +0200 |
| commit | 5f30f566873c313b554f087c0f7e132efa54d64a (patch) | |
| tree | 4f6c33a0a1e9a6b2b1ad29bffef4defd0990fef2 | |
| parent | 79b88e0a8cb4dfc9c33fe219045f3da7f64256c4 (diff) | |
Recognise word-anchored insertions; close quotes at the next item
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 <noreply@anthropic.com>
Change-Id: Ic579bec6fecc2495a61b3dcf87c9ad71b42f34ae
13 files changed, 547 insertions, 39 deletions
diff --git a/README.adoc b/README.adoc index d3a8eb5..dc1893d 100644 --- a/README.adoc +++ b/README.adoc @@ -107,13 +107,20 @@ ersetzt, eingefügt, gestrichen — sie werden auf die Inhaltsübersichts-Norm angewandt), das Einfügen und Ersetzen von Gliederungs-Überschriften („Nach § 33 werden die folgenden Überschriften zu Teil 3 … eingefügt“), Voranstellungen, Mehrfach-Ersetzungs- und --Einfügepaare, Verbünde aus Umnummerierung und Folgeänderung („§ 50 wird -zu § 38 und wird wie folgt geändert“) sowie die Neufassung der -Gesetzesüberschrift. Die PDF-Aufbereitung toleriert dabei +-Einfügepaare, Einfügungen, deren Position ein Wortanker statt einer +Stellenangabe bestimmt („Vor den Wörtern „Aus dem Bereich Verkehr:“ wird +folgender Absatz 5 eingefügt“), Verbünde aus Umnummerierung und +Folgeänderung („§ 50 wird zu § 38 und wird wie folgt geändert“) sowie die +Neufassung der Gesetzesüberschrift. Die PDF-Aufbereitung toleriert dabei Drucksachen-Artefakte (Seitenköpfe und -füße, Vorabfassungs-Wasserzeichen, -vertauschte oder gerade Anführungszeichen, verklebte Wortgrenzen) und -bestimmt die Brotschrift seitenweise, sodass auch Ministeriumsentwürfe -mit gemischten Layouts vollständig extrahiert werden. Auf den +vertauschte oder gerade Anführungszeichen, verklebte Wortgrenzen, zerlegt +kodierte Umlaute) und bestimmt die Brotschrift seitenweise, sodass auch +Ministeriumsentwürfe mit gemischten Layouts vollständig extrahiert +werden. Fehlt im amtlichen Satz ein schließendes Anführungszeichen, endet +das Zitat an der nächsten Strukturgrenze — an einer Artikel-Überschrift +oder, wo die Anführungszeichen eines Artikels nachweislich nicht +aufgehen, am nächsten Aufzählungspunkt des Änderungsgesetzes; gemeldet +wird das als Warnung. Auf den Beispieldaten werden damit alle Befehle der BGBl-Fassungen und der aktuellen Entwürfe angewandt (UWG/AGG/ProdHaftG: 0 manuell); was unsicher bleibt — etwa Befehle gegen eine ältere Gesetzesfassung, deren @@ -141,9 +148,9 @@ Paragraphen; die Unterschiede liegen im Gesetzblatt-Satz und in Befehlsidiomen. Belegt sind Sachsen (SächsBeamtVG), Niedersachsen (NEFG) und Nordrhein-Westfalen (Telemedienzuständigkeitsgesetz) mit vollen Akzeptanztests ohne Rest; für Schleswig-Holstein und Berlin -reicht die Prüfung bis zur Befehlserkennung, weil deren Landesportale -ihre Stammfassungen nur über eine anmeldepflichtige Schnittstelle -ausgeben. Welche Konvention welches Land beisteuert, welche +reicht die Prüfung bis zur Befehlserkennung — dort vollständig, aber ohne +Anwendung —, weil deren Landesportale ihre Stammfassungen nur über eine +anmeldepflichtige Schnittstelle ausgeben. Welche Konvention welches Land beisteuert, welche Stammfassungen woher stammen und was noch offen ist, verzeichnet `src/main/resources/sampledata/Landesrecht-Beispiele.adoc`. diff --git a/src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java b/src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java index 897f02c..606383f 100644 --- a/src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java +++ b/src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java @@ -73,6 +73,9 @@ public sealed interface Aenderungsbefehl { * @param vorher {@code true} bei „Vor …“, {@code false} bei „Nach …“. * @param bezeichnung die Bezeichnung des neuen Elements (z.B. „28a“, „5a“); {@code null}, wenn * der Befehl keine nennt (z.B. „folgender Satz“). + * @param anker bei „Vor den Wörtern „Aus dem Bereich Verkehr:“ wird folgender Absatz 5 + * eingefügt“ der Wortanker, der die Position innerhalb von {@code stelle} bestimmt; + * {@code null}, wenn die Position wie üblich aus der Struktur folgt. */ record StrukturEinfuegung( Stelle stelle, @@ -80,8 +83,21 @@ public sealed interface Aenderungsbefehl { Ebene ebene, @Nullable String bezeichnung, String text, + @Nullable WortAnker anker, Provenienz provenienz) - implements Aenderungsbefehl {} + implements Aenderungsbefehl { + + /** Konstruktor für die strukturbestimmte Position (kein Wortanker). */ + public StrukturEinfuegung( + Stelle stelle, + boolean vorher, + Ebene ebene, + @Nullable String bezeichnung, + String text, + Provenienz provenienz) { + this(stelle, vorher, ebene, bezeichnung, text, null, provenienz); + } + } /** „Folgender Absatz 9 wird angefügt: „…““ / „Dem Absatz 3 wird folgender Satz angefügt: …“ */ record Anfuegung( 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 80206f6..dc5a54e 100644 --- a/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java +++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java @@ -206,6 +206,19 @@ final class BefehlErkenner { + Z + "\\.?$"); + // „Vor den Wörtern „Aus dem Bereich Verkehr:“ wird folgender Absatz 5 eingefügt: „…““ (GVBl. für + // Berlin 17/2026, Artikel 1 Nr. 2 b) bb)) — die Position der neuen Einheit bestimmt ein Wortanker + // statt einer Stellenangabe; das Ziel selbst erbt der Befehl aus dem Kontextrahmen. + private static final Pattern STRUKTUR_EINFUEGUNG_WORTANKER = + Pattern.compile( + "^(Nach|Vor) (?:dem Wort|den Wörtern|der Angabe|der Zahl) " + + Z + + " (?:wird|werden) (?:der |die |das )?folgende[nrs]? (?:neue[nrs]? )?(.+?) " + + "(?:ein|an)gefügt: " + + ENUM + + Z + + "\\.?$"); + // „In Kapitel 4 wird nach § 12 der folgende neue § 13 angefügt: „…““ — gliederungsbezogene // Einfügung. Die Gliederungsangabe nennt nur den Abschnitt, in dem die neue Einheit landet; // maßgeblich für die Position ist der Anker („nach § 12“), der ohnehin eindeutig ist. @@ -909,6 +922,35 @@ final class BefehlErkenner { provenienz)); } + // Vor STRUKTUR_EINFUEGUNG: dort scheitert die Wortanker-Form am StellenParser („den Wörtern + // «0»“) und verließe die Erkennung mit Optional.empty(), sodass kein späteres Muster mehr zum + // Zuge käme. + if ((m = STRUKTUR_EINFUEGUNG_WORTANKER.matcher(text)).matches()) { + var ebeneBez = ebeneUndBezeichnung(m.group(3)); + if (ebeneBez.isEmpty()) { + return Optional.empty(); + } + var ankerWoerter = wortZitat(zitate, m.group(2)); + var anker = + m.group(1).equalsIgnoreCase("nach") + ? new WortAnker.NachWoertern(ankerWoerter) + : new WortAnker.VorWoertern(ankerWoerter); + var textInhalt = + mitEnumerator( + m.group(4), + labelFuer(ebeneBez.get().ebene(), ebeneBez.get().bezeichnung()), + zitat(zitate, m.group(5))); + return Optional.of( + new StrukturEinfuegung( + kontext, + m.group(1).equalsIgnoreCase("vor"), + ebeneBez.get().ebene(), + ebeneBez.get().bezeichnung(), + textInhalt, + anker, + provenienz)); + } + if ((m = STRUKTUR_EINFUEGUNG.matcher(text)).matches() || (m = STRUKTUR_EINFUEGUNG_IN_GLIEDERUNG.matcher(text)).matches()) { var vorher = m.group(1).equalsIgnoreCase("vor"); diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java index fc54554..3945df9 100644 --- a/src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java +++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java @@ -1,6 +1,7 @@ package eu.mulk.aendggner.aenderung.parse; import eu.mulk.aendggner.gesetz.Superskript; +import java.text.Normalizer; import java.util.ArrayList; import java.util.List; import java.util.regex.Pattern; @@ -212,6 +213,13 @@ public final class TextBereiniger { private TextBereiniger() {} public static String bereinige(String rohText) { + // Manche Gesetzblatt-PDFs kodieren einen Teil ihrer Umlaute zerlegt (GV. NRW. 2026 S. 202: + // „angefu“ + U+0308 + „gt“, 79 Stellen). Für die Befehlsmuster ist ein solches „angefügt“ ein + // anderes Wort — dutzende Einfüge- und Anfügebefehle könnten nie matchen. Deshalb ganz früh + // kanonisch zusammensetzen. NFC, nicht NFKC: NFKC plättete die amtlichen Satznummern ¹²³ zu + // gewöhnlichen Ziffern und nähme SatzTeiler und Superskript ihre Grundlage. Die Umbruch-Marker + // des FontgroessenFilters liegen im Private-Use-Bereich und bleiben unberührt. + rohText = Normalizer.normalize(rohText, Normalizer.Form.NFC); // Geschützte Leerzeichen (GVBl-Satz: „§ 1“, „Abs. 2“) sind für Javas \s und // String.strip unsichtbar — früh auf gewöhnliche Leerzeichen normalisieren. var text = rohText.replace(' ', ' ').replace(' ', ' '); diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/ZitatExtraktor.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/ZitatExtraktor.java index dc7dac8..d4df176 100644 --- a/src/main/java/eu/mulk/aendggner/aenderung/parse/ZitatExtraktor.java +++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/ZitatExtraktor.java @@ -4,6 +4,7 @@ import java.util.ArrayList; import java.util.List; import java.util.regex.Matcher; import java.util.regex.Pattern; +import org.jspecify.annotations.Nullable; /** * Ersetzt in deutschem Gesetzestext alle auf oberster Ebene mit „…“ zitierten Passagen durch @@ -18,6 +19,11 @@ import java.util.regex.Pattern; * Extraktionsartefakte). Der Extraktor bricht deshalb nicht ab: Ein schließendes Anführungszeichen * ohne offenes Zitat wird als Literal übernommen, am Textende offene Zitate werden dort geschlossen * — beides wird als Warnung gemeldet und darf nicht stillschweigend untergehen. + * + * <p>Damit ein solcher Satzfehler nicht den halben Text verschlingt, gibt es zwei Strukturgrenzen, + * an denen ein offenes Zitat endet: die Artikel-Überschrift (immer) und der nächste + * Aufzählungspunkt des Änderungsgesetzes (nur in Abschnitten, deren Anführungszeichen nachweislich + * nicht aufgehen — siehe {@link #aufzaehlungsGrenze}). */ public final class ZitatExtraktor { @@ -53,13 +59,22 @@ public final class ZitatExtraktor { } public static Ergebnis extrahiere(String text) { + var segmente = artikelSegmente(text); var ausgabe = new StringBuilder(); var zitate = new ArrayList<String>(); var warnungen = new ArrayList<String>(); var aktuellesZitat = new StringBuilder(); int tiefe = 0; + int segment = 0; + // Letzter Aufzählungsmarker, der außerhalb eines Zitats am Zeilenanfang stand, und sein Stand + // beim Aufgehen des laufenden Zitats — die Bezugsgröße der Aufzählungs-Grenze. + var letzterMarker = marker(text, 0); + String markerVorZitat = null; for (int i = 0; i < text.length(); i++) { + while (segment + 1 < segmente.size() && i >= segmente.get(segment + 1).von()) { + segment++; + } char c = text.charAt(i); if (c == OEFFNEND) { if (tiefe > 0 && istFortfuehrungszeichen(text, i)) { @@ -70,6 +85,7 @@ public final class ZitatExtraktor { } if (tiefe == 0) { aktuellesZitat.setLength(0); + markerVorZitat = letzterMarker; } else { aktuellesZitat.append(c); } @@ -91,6 +107,10 @@ public final class ZitatExtraktor { aktuellesZitat.append(c); } } else if (tiefe > 0) { + var grenzMarker = + c == '\n' + ? aufzaehlungsGrenze(text, i + 1, markerVorZitat, segmente.get(segment)) + : null; if (c == '\n' && beginntArtikelUeberschrift(text, i + 1)) { // Ein Änderungsgesetz zitiert nie über eine Artikel-Überschrift hinweg: Hier fehlt im // amtlichen Satz ein schließendes Anführungszeichen (kommt vor, z.B. GV. NRW. 2026 @@ -105,10 +125,29 @@ public final class ZitatExtraktor { aktuellesZitat.setLength(0); tiefe = 0; ausgabe.append(c); + } else if (grenzMarker != null) { + warnungen.add( + "Zitat vor dem Aufzählungspunkt „" + + grenzMarker + + "“ nicht geschlossen: …" + + kontextAuszug(text, i) + + "… — dort geschlossen."); + ausgabe.append('«').append(zitate.size()).append('»'); + zitate.add(aktuellesZitat.toString()); + aktuellesZitat.setLength(0); + tiefe = 0; + ausgabe.append(c); + letzterMarker = grenzMarker; } else { aktuellesZitat.append(c); } } else { + if (c == '\n') { + var neuerMarker = marker(text, i + 1); + if (neuerMarker != null) { + letzterMarker = neuerMarker; + } + } ausgabe.append(c); } } @@ -159,8 +198,133 @@ public final class ZitatExtraktor { if (von >= text.length()) { return false; } + return ARTIKEL_GRENZE.matcher(zeileAb(text, von)).matches(); + } + + // --- Grenze am Aufzählungspunkt ------------------------------------------------------------ + + /** Ein Artikel-Abschnitt des Änderungsgesetzes samt Befund, ob darin ein Zitat offen bleibt. */ + private record Segment(int von, int bis, boolean defekt) {} + + /** Zeile am Zeilenanfang: Aufzählungsmarker („1.“, „2a.“, „b)“, „aa)“) und Zeilenrest. */ + private static final Pattern AUFZAEHLUNGSZEILE = + Pattern.compile("^[ \\t]*(\\d+[a-z]?\\.|[a-z]{1,3}\\))[ \\t]+(.*)$"); + + /** + * Befehlssprache am Zeilenende — die Verbformen des Handbuchs der Rechtsförmlichkeit samt der + * Umnummerierungsform („werden die Absätze 6 bis 10.“). Zitierter Gesetzestext endet so gut wie + * nie auf eine dieser Wendungen; eine zitierte *Änderungs*vorschrift dagegen sehr wohl, weshalb + * dieses Merkmal allein nicht ausreicht (siehe {@link #aufzaehlungsGrenze}). + */ + private static final Pattern BEFEHLSSPRACHE = + Pattern.compile( + "(?:(?:wird|werden) wie folgt (?:geändert|gefasst|neu gefasst)" + + "|(?:erhält|erhalten) folgende Fassung" + + "|(?:wird|werden)(?: \\S+){0,12} " + + "(?:eingefügt|angefügt|ersetzt|vorangestellt|aufgehoben|gestrichen)" + + "|(?:wird|werden) (?:zu )?(?:die |der |das |den )?" + + "(?:§§?|Artt?\\.|Absatz|Absätze|Absätzen|Nummer|Nummern|Satz|Sätze|Sätzen" + + "|Buchstabe|Buchstaben) \\d+[a-z]?(?: bis \\d+[a-z]?)?" + + ")[ \\t]*[:.]?[ \\t]*$"); + + /** + * Prüft, ob das offene Zitat vor der bei {@code von} beginnenden Zeile zu schließen ist, weil + * dort die Aufzählung des Änderungsgesetzes weitergeht, und liefert dann deren Marker. + * + * <p>Ein Zitat darf hier nur unter allen vier Bedingungen zusammen enden, denn zitierter + * Gesetzestext enthält selbst Aufzählungen — und eine zitierte Änderungsvorschrift (bayerische + * GVBl-Hefte, Meta-Änderungen) sogar Befehlssprache: + * + * <ol> + * <li>Im Artikel-Abschnitt bleibt am Ende nachweislich ein Zitat offen, der Satz ist dort also + * defekt. In fehlerfreien Abschnitten wird nie geraten. + * <li>Der Marker der Folgezeile liegt auf derselben oder einer flacheren Ebene als der Marker, + * auf dessen Punkt das Zitat aufging („bb)“ → „cc)“, „c)“, „3.“). + * <li>Der Zeilenrest trägt Befehlssprache. + * <li>Ein Schluss an dieser Stelle lässt den Rest des Abschnitts ausbalanciert zurück — die + * Stelle behebt den Defekt also wirklich. + * </ol> + */ + private static @Nullable String aufzaehlungsGrenze( + String text, int von, @Nullable String markerVorZitat, Segment segment) { + if (markerVorZitat == null || !segment.defekt() || von >= text.length()) { + return null; + } + var zeile = AUFZAEHLUNGSZEILE.matcher(zeileAb(text, von)); + if (!zeile.matches()) { + return null; + } + var marker = zeile.group(1); + if (markerEbene(marker) > markerEbene(markerVorZitat) + || !BEFEHLSSPRACHE.matcher(zeile.group(2)).find() + || offeneZitate(text, von, segment.bis()) != 0) { + return null; + } + return marker; + } + + /** Gliederungsebene eines Aufzählungsmarkers: „1.“ = 1, „b)“ = 2, „aa)“ = 3, „aaa)“ = 4. */ + private static int markerEbene(String marker) { + return marker.endsWith(")") ? marker.length() : 1; + } + + /** Der Aufzählungsmarker der bei {@code von} beginnenden Zeile, sonst {@code null}. */ + private static @Nullable String marker(String text, int von) { + if (von >= text.length()) { + return null; + } + var zeile = AUFZAEHLUNGSZEILE.matcher(zeileAb(text, von)); + return zeile.matches() ? zeile.group(1) : null; + } + + /** Zerlegt den Text an den Artikel-Überschriften und hält je Abschnitt fest, ob er defekt ist. */ + private static List<Segment> artikelSegmente(String text) { + var anfaenge = new ArrayList<Integer>(); + anfaenge.add(0); + for (int i = 0; i < text.length(); ) { + int ende = text.indexOf('\n', i); + if (i > 0 && ARTIKEL_GRENZE.matcher(zeileAb(text, i)).matches()) { + anfaenge.add(i); + } + if (ende < 0) { + break; + } + i = ende + 1; + } + var segmente = new ArrayList<Segment>(anfaenge.size()); + for (int k = 0; k < anfaenge.size(); k++) { + int von = anfaenge.get(k); + int bis = k + 1 < anfaenge.size() ? anfaenge.get(k + 1) : text.length(); + segmente.add(new Segment(von, bis, offeneZitate(text, von, bis) > 0)); + } + return segmente; + } + + /** + * Zahl der am Ende von {@code [von, bis)} noch offenen Zitate — dieselbe Tiefenzählung wie {@link + * #extrahiere} (samt Fortführungszeichen), aber ohne Ausgabe. Ein schließendes Anführungszeichen + * ohne offenes Zitat gilt wie dort als Literal. + */ + private static int offeneZitate(String text, int von, int bis) { + int tiefe = 0; + for (int i = von; i < bis; i++) { + char c = text.charAt(i); + if (c == OEFFNEND) { + if (tiefe > 0 && istFortfuehrungszeichen(text, i)) { + continue; + } + tiefe++; + } else if (c == SCHLIESSEND && tiefe > 0) { + tiefe--; + } + } + return tiefe; + } + + /** Die bei {@code von} beginnende Zeile (ohne Zeilenumbruch). */ + private static String zeileAb(String text, int von) { int ende = text.indexOf('\n', von); - return ARTIKEL_GRENZE.matcher(text.substring(von, ende < 0 ? text.length() : ende)).matches(); + return text.substring(von, ende < 0 ? text.length() : ende); } private static String kontextAuszug(String text, int position) { diff --git a/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java index b2e855d..66ea9b3 100644 --- a/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java +++ b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java @@ -706,6 +706,10 @@ public final class BefehlAnwender { private static AngewandteAenderung wendeStrukturEinfuegungAn( List<Norm> normen, StrukturEinfuegung befehl) { + var wortAnker = befehl.anker(); + if (wortAnker != null) { + return wendeWortankerEinfuegungAn(normen, befehl, wortAnker); + } return switch (befehl.ebene()) { case PARAGRAPH -> { var aufloesung = loeseNormAuf(normen, befehl.stelle()); @@ -794,6 +798,52 @@ public final class BefehlAnwender { }; } + /** + * „Vor den Wörtern „Aus dem Bereich Verkehr:“ wird folgender Absatz 5 eingefügt: „…““ — die + * Position der neuen Einheit bestimmt hier ein Wortanker, nicht die Struktur. Der Einfügeblock + * tritt deshalb als eigene Zeile vor bzw. hinter die Zeile des Ankers; welche strukturelle Ebene + * der Befehl nennt, ist dabei ohne Belang (in einer Anlage stehen Absätze und Nummern als Zeilen + * eines Textes). + */ + private static AngewandteAenderung wendeWortankerEinfuegungAn( + List<Norm> normen, StrukturEinfuegung befehl, WortAnker anker) { + return bearbeiteText( + normen, + befehl, + ohneFussnoten( + text -> { + var woerter = + switch (anker) { + case WortAnker.NachWoertern nach -> nach.woerter(); + case WortAnker.VorWoertern vor -> vor.woerter(); + // „am Ende“ ist kein Einfügeanker für ganze Einheiten — dafür gibt es die + // Anfügung. + case WortAnker.AmEnde ignoriert -> null; + case WortAnker.VorKommaAmEnde ignoriert -> null; + }; + if (woerter == null) { + return TextErgebnis.fehler( + "Einfügeanker ohne Wortlaut wird für Struktureinfügungen nicht unterstützt."); + } + var pruefung = eindeutigeFundstelle(text, woerter); + if (pruefung.fehler() != null) { + return TextErgebnis.fehler(pruefung.fehler()); + } + int zeilenAnfang = text.lastIndexOf('\n', pruefung.index()) + 1; + int zeilenEnde = text.indexOf('\n', pruefung.index()); + if (zeilenEnde < 0) { + zeilenEnde = text.length(); + } + var block = + rueckeZitatEin( + normalisiereZitatText(befehl.text()), einrueckungVon(text, zeilenAnfang)); + return TextErgebnis.ok( + befehl.vorher() + ? text.substring(0, zeilenAnfang) + block + "\n" + text.substring(zeilenAnfang) + : text.substring(0, zeilenEnde) + "\n" + block + text.substring(zeilenEnde)); + })); + } + private static AngewandteAenderung wendeAnfuegungAn(List<Norm> normen, Anfuegung befehl) { return switch (befehl.ebene()) { case ABSATZ -> { diff --git a/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtLoader.java b/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtLoader.java index 6bd72ce..ecb10f8 100644 --- a/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtLoader.java +++ b/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtLoader.java @@ -6,6 +6,7 @@ import eu.mulk.aendggner.aenderung.parse.TextBereiniger; import eu.mulk.aendggner.gesetz.Gesetz; import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.text.Normalizer; import java.nio.file.Files; import java.nio.file.Path; import org.apache.tika.Tika; @@ -40,7 +41,11 @@ public final class LandesRechtLoader { nachSatzendeGetrennteNormkoepfe( TextBereiniger.bereinige( new PatchTextExtraktor(SuperskriptModus.BEHALTEN).extrahiere(datei))); - case "text/plain" -> Files.readString(datei, StandardCharsets.UTF_8); + // Auch der handgepflegte Klartext wird kanonisch zusammengesetzt (NFC), damit Stammtext + // und Befehlstext gleich kodiert sind — der PDF-Zweig erledigt das über bereinige(). + case "text/plain" -> + Normalizer.normalize( + Files.readString(datei, StandardCharsets.UTF_8), Normalizer.Form.NFC); default -> throw new IOException( "Nicht unterstützter Dateityp %s für Stammgesetz %s (unterstützt: PDF, Klartext)" diff --git a/src/main/resources/sampledata/Landesrecht-Beispiele.adoc b/src/main/resources/sampledata/Landesrecht-Beispiele.adoc index dbfe8b7..9a661f3 100644 --- a/src/main/resources/sampledata/Landesrecht-Beispiele.adoc +++ b/src/main/resources/sampledata/Landesrecht-Beispiele.adoc @@ -106,8 +106,10 @@ folgt geändert"). abgeglichen. Die übrigen Artikel (WDR-Gesetz, Landesmediengesetz, 17. RÄStV-AusfG) bleiben offen. * *Berlin* ist bis zur Befehlserkennung umgesetzt (`EndToEndTest.asogLafAendGBerlin`): Artikel 2 (LAF-Errichtungsgesetz) wird vollständig erkannt, beide Punkte erben ihr Ziel aus der - Änderungsformel. Von Artikel 1 werden drei der vier Befehle erkannt, einschließlich der - Änderungen an der unnummerierten Anlage; der vierte nutzt einen Wortanker (siehe „Noch offen“). + Änderungsformel. Artikel 1 wird seit der Wortanker-Einfügung und der Zitatgrenze am + Aufzählungspunkt *vollständig* erkannt — alle sechs Befehle, einschließlich der Änderungen an der + unnummerierten Anlage. Ein voller Akzeptanztest bleibt an der Stammfassung hängen (siehe „Noch + offen“). * Baden-Württemberg (GBl 2026 Nr. 26): großes Anlage-lastiges Änderungsgesetz (§§ 4–57a + Anlagen 1/3b/4b); Stammfassung/Akzeptanztest noch offen. @@ -142,6 +144,13 @@ Fällt das Portal aus, bleibt der Weg über das Gesetzblatt selbst: Trägt der E Stammfassung (so bei Niedersachsen). Die Extraktion zweispaltiger Hefte übernimmt `--extract-only` des Werkzeugs selbst; `pdftotext -layout` verschränkt dort die Spalten. +Ein Fallstrick beim Prüfen extrahierter Hefte: Das GV.-NRW.-Heft 7/2026 kodiert einen Teil seiner +Umlaute *zerlegt* (`u` + U+0308 statt `ü`, 79 Stellen, darunter dutzende „eingefügt:“/„angefügt:“). +Im Text ist das unsichtbar, für jedes Befehlsmuster aber ein anderes Wort. `TextBereiniger.bereinige` +normalisiert deshalb ganz früh nach NFC — nicht NFKC, das plättete die amtlichen Satznummern ¹²³ zu +gewöhnlichen Ziffern. Wer Klartext von Hand aufbereitet, sollte ihn ebenfalls in NFC ablegen; der +Klartext-Zweig des Loaders normalisiert zur Sicherheit mit. + == Noch offen * *Schleswig-Holstein — Stammfassung nicht beschaffbar*: Das Landesportal @@ -163,14 +172,6 @@ des Werkzeugs selbst; `pdftotext -layout` verschränkt dort die Spalten. „Art.“-Normköpfe: Eine handgepflegte Stammfassung kann *keine* Anlage tragen. Das ist die konkrete Lücke für Berlin (Artikel 1) und Baden-Württemberg (Anlagen 1/3b/4b) — sie wird erst behebbar, wenn für eines der beiden eine Stammfassung vorliegt. -* *Struktureinfügung mit Wortanker*: „Vor den Wörtern „Aus dem Bereich Verkehr:“ wird folgender - Absatz 5 eingefügt“ (Berlin Artikel 1 Nr. 2 b) bb)) — die Position der neuen Einheit bestimmt - ein Wortanker statt einer Stellenangabe. `StrukturEinfuegung` trägt bislang nur eine Stelle. -* *Unbalancierte Anführungszeichen innerhalb eines Artikels*: Die Strukturgrenze des - Zitatextraktors greift nur an Artikel-Überschriften. In Berlin Artikel 1 fehlt bei Nummer 2 b) bb) - das schließende Anführungszeichen; das Zitat verschlingt daher die folgenden Punkte cc) und c). - Eine Grenze an Aufzählungsmarkern ist nicht möglich — zitierter Gesetzestext enthält selbst - Aufzählungen (dafür gibt es die Fortführungszeichen-Erkennung). * *Berlin, ganzseitenbreite Rahmen*: Titelblock und Impressum stehen im Inhaltsstrom an anderer Stelle als im Satzbild (der Titelblock mitten im Fließtext der ersten Seite). Das zu beheben verlangt eine geometrische Lesereihenfolge (XY-Cut), nicht bloß eine Spaltenerkennung. @@ -184,13 +185,13 @@ des Werkzeugs selbst; `pdftotext -layout` verschränkt dort die Spalten. Kein Land scheitert noch an reiner Extraktionsschwäche; offen sind Modell- und Beschaffungsfragen. Nach absteigendem Nutzen: -. *Struktureinfügung mit Wortanker* (siehe „Noch offen“). Rein im Modell zu lösen, ohne neue - Stammfassung: `StrukturEinfuegung` um einen Wortanker ergänzen, wie ihn `WoerterEinfuegung` - bereits trägt. Belegfall ist Berlin Artikel 1 Nr. 2 b) bb); prüfbar bis zur Befehlserkennung. . *Weitere Artikel des NRW-Heftes* (WDR-Gesetz, Landesmediengesetz). Die Stammfassungen sind über das oben beschriebene recht.nrw.de-Rezept unmittelbar greifbar, und die Nachfassungen erlauben - wieder den Abgleich gegen den amtlichen Text. Ergibt einen Massentest mit über hundert Befehlen. -. *Anlagen-Normköpfe im `LandesRechtTextParser`* — lohnt erst zusammen mit (2) oder sobald für + wieder den Abgleich gegen den amtlichen Text. Ergibt einen Massentest mit über hundert Befehlen: + Seit der NFC-Normalisierung erkennt der Parser in Artikel 1 (WDR-Gesetz) 103 Befehle, davon 20 + noch nicht, in Artikel 2 (Landesmediengesetz) 18 Befehle, davon 5 noch nicht — diese Reste sind + der eigentliche Ertrag des Massentests. +. *Anlagen-Normköpfe im `LandesRechtTextParser`* — lohnt erst zusammen mit (1) oder sobald für Berlin bzw. Baden-Württemberg eine Stammfassung vorliegt, sonst nicht end-to-end prüfbar. . *Hessen* als Sperrsatz-Härtetest („3 . F e bru a r 2 02 6“). Betrifft nur Datums- und Signaturzeilen, nicht die Befehle — kosmetisch, aber ein eigener Extraktionsfall. 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 { * * <p>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 @@ -162,6 +162,31 @@ class BefehlErkennerTest { } @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 = erkenne( 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 @@ -20,6 +20,25 @@ class TextBereinigerTest { } @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 @@ -94,6 +94,68 @@ class ZitatExtraktorTest { } @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."; var ergebnis = ZitatExtraktor.extrahiere(original); 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 @@ -219,6 +219,91 @@ class BefehlAnwenderTest { } @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 = new WoerterEinfuegung( |
