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
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 @@
* @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 @@
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 @@
+ 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 @@
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 @@
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.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 @@
* 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 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 @@
}
if (tiefe == 0) {
aktuellesZitat.setLength(0);
+ markerVorZitat = letzterMarker;
} else {
aktuellesZitat.append(c);
}
@@ -91,6 +107,10 @@
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 @@
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 @@
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 @@
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 @@
};
}
+ /**
+ * „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.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 @@
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 @@
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 @@
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 @@
„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 @@
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.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 @@
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 @@
*
* <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 @@
"§ 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 @@
}
@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 @@
}
@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 @@
}
@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 @@
}
@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(