Recognize compound and multi-pair amendment commands
Split BefehlErkenner into a single-command pass and a fallback that
composes Sammelbefehle from commands chained with "und"/", wird":
- Multi-pair replacement ("… A durch B und C durch D ersetzt") emits one
Ersetzung per pair, crossed with the (possibly coordinated) Stelle.
- Verbund splitter probes each "und"/", wird" boundary; when both halves
parse — trying the right clause as-is, capitalized, or with the left
clause's locative prefix — it folds them into one Sammelbefehl.
The single pass no longer short-circuits when a pattern matches but its
Stelle is unparseable, so the fallback still gets a chance. Add
"ein Komma eingefügt" and anchor-first insertion patterns the splitter
needs, and let StellenParser.parseMehrfach inherit the component type for
bare-number continuations ("Absatz 1 und 5").
Cuts UnbekannterBefehl counts: GEG 50->36, IfSG 11->7, AGG/UWG 2->1.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Change-Id: I8f76edd0c9ed6cf68c10c50f535a8182e13e378e
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 ca59b12..0ef0973 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java
@@ -162,6 +162,41 @@
+ "|Nummer (\\d+[a-z]?)|Buchstabe ([a-z]{1,3})"
+ "|(Absätze .+|Nummern .+|Buchstaben .+))$");
+ // „In <Stelle> werden die Wörter «1» durch die Wörter «2» und die Angabe «3» durch die Wörter «4»
+ // ersetzt.“ — mehrere Ersetzungspaare unter einem gemeinsamen „ersetzt“. Die Mitte (Gruppe 2)
+ // wird an „ und “ in Einzelpaare zerlegt und je gegen EIN_ERSETZUNGS_PAAR validiert.
+ private static final Pattern PAAR_ERSETZUNG =
+ Pattern.compile("^(?:In )?(.+?) (?:wird|werden) (.+ und .+) ersetzt\\.$");
+ private static final Pattern EIN_ERSETZUNGS_PAAR =
+ Pattern.compile(
+ "^(?:jeweils )?" + WOERTER + " " + Z + " (?:jeweils )?durch " + WOERTER + " " + Z + "$");
+
+ // „… ein Komma eingefügt und werden …“: Trennstellen eines Verbundbefehls sind „ und “ (ggf. mit
+ // Komma) bzw. „, “ direkt vor „wird/werden“. Innerhalb von Zitaten steht „ und “ als «n» maskiert.
+ private static final Pattern VERBUND_SEP =
+ Pattern.compile(",? und |, (?=wird\\b|werden\\b)");
+ private static final Pattern WIRD_WERDEN = Pattern.compile(" (?:wird|werden) ");
+
+ // „In <Stelle> wird nach den Wörtern «1» ein Komma eingefügt.“ (Satzzeichen statt Wörter einfügen)
+ private static final Pattern KOMMA_EINFUEGUNG =
+ Pattern.compile(
+ "^(?:In )?(.+?) (?:wird|werden) (?:jeweils )?(nach|vor) "
+ + "(?:dem Wort|den Wörtern|der Angabe|der Zahl) "
+ + Z
+ + " (ein Komma|ein Semikolon|einen Punkt) eingefügt\\.$");
+
+ // „Nach der Angabe «1» wird die Angabe «2» eingefügt.“ — Anker zuerst, ohne eigene Stelle (nutzt
+ // den Kontext). Tritt vor allem als rechte Klausel eines Verbundbefehls auf.
+ private static final Pattern EINFUEGUNG_ANKER_ZUERST =
+ Pattern.compile(
+ "^(Nach|Vor) (?:dem Wort|den Wörtern|der Angabe|der Zahl) "
+ + Z
+ + " (?:wird|werden) (?:"
+ + WOERTER
+ + " "
+ + Z
+ + "|(ein Komma|ein Semikolon)) eingefügt\\.$");
+
private BefehlErkenner() {}
/** Prüft, ob der Text ein Kontextrahmen („§ X wird wie folgt geändert:“) ist. */
@@ -174,7 +209,9 @@
}
/**
- * Versucht, den Text als Änderungsbefehl zu erkennen.
+ * Versucht, den Text als Änderungsbefehl zu erkennen. Zuerst als Einzelbefehl ({@link
+ * #erkenneEinzeln}); schlägt das fehl (kein Muster passt oder die Stelle ist unparsbar), wird der
+ * Text als Mehrfach-Ersetzung bzw. Verbundbefehl gedeutet.
*
* @param text platzhalter-substituierter, whitespace-normalisierter Befehlstext.
* @param kontext die aus umgebenden Kontextrahmen geerbte Stelle.
@@ -183,6 +220,19 @@
*/
static Optional<Aenderungsbefehl> erkenne(
String text, Stelle kontext, ZitatExtraktor.Ergebnis zitate, Provenienz provenienz) {
+ var einzeln = erkenneEinzeln(text, kontext, zitate, provenienz);
+ if (einzeln.isPresent()) {
+ return einzeln;
+ }
+ var paare = erkennePaarErsetzung(text, kontext, zitate, provenienz);
+ if (paare.isPresent()) {
+ return paare;
+ }
+ return erkenneVerbund(text, kontext, zitate, provenienz);
+ }
+
+ private static Optional<Aenderungsbefehl> erkenneEinzeln(
+ String text, Stelle kontext, ZitatExtraktor.Ergebnis zitate, Provenienz provenienz) {
Matcher m;
@@ -268,6 +318,27 @@
kontext.plus(s), new WortAnker.VorKommaAmEnde(), woerter, provenienz));
}
+ if ((m = KOMMA_EINFUEGUNG.matcher(text)).matches()) {
+ var ankerWoerter = wortZitat(zitate, m.group(3));
+ var anker =
+ m.group(2).equals("nach")
+ ? new WortAnker.NachWoertern(ankerWoerter)
+ : new WortAnker.VorWoertern(ankerWoerter);
+ var woerter = satzzeichen(m.group(4));
+ return ausStellen(
+ m.group(1), s -> new WoerterEinfuegung(kontext.plus(s), anker, woerter, provenienz));
+ }
+
+ if ((m = EINFUEGUNG_ANKER_ZUERST.matcher(text)).matches()) {
+ var ankerWoerter = wortZitat(zitate, m.group(2));
+ var anker =
+ m.group(1).equalsIgnoreCase("nach")
+ ? new WortAnker.NachWoertern(ankerWoerter)
+ : new WortAnker.VorWoertern(ankerWoerter);
+ var woerter = m.group(3) != null ? wortZitat(zitate, m.group(3)) : satzzeichen(m.group(4));
+ return Optional.of(new WoerterEinfuegung(kontext, anker, woerter, provenienz));
+ }
+
if ((m = INHALTSUEBERSICHT_EINFUEGUNG.matcher(text)).matches()
|| (m = ANGABE_EINFUEGUNG.matcher(text)).matches()) {
var anker =
@@ -367,6 +438,127 @@
}
/**
+ * „In <Stelle> werden die Wörter «1» durch «2» und die Angabe «3» durch «4» ersetzt.“ — mehrere
+ * Ersetzungspaare unter einem gemeinsamen „ersetzt“. Bei koordinierter Stelle („Absatz 1 und 5“)
+ * wird das Kreuzprodukt aus Stellen und Paaren gebildet. Ergebnis ist ein {@link Sammelbefehl}
+ * (bzw. ein einzelner Befehl, falls nur eine Kombination entsteht).
+ */
+ private static Optional<Aenderungsbefehl> erkennePaarErsetzung(
+ String text, Stelle kontext, ZitatExtraktor.Ergebnis zitate, Provenienz provenienz) {
+ var m = PAAR_ERSETZUNG.matcher(text);
+ if (!m.matches()) {
+ return Optional.empty();
+ }
+ var stellen = StellenParser.parseMehrfach(m.group(1));
+ if (stellen.isEmpty()) {
+ return Optional.empty();
+ }
+ var segmente = m.group(2).split(" und ");
+ if (segmente.length < 2) {
+ return Optional.empty();
+ }
+ var jeweils = text.contains("jeweils");
+ record Paar(String alt, String neu) {}
+ var paareListe = new ArrayList<Paar>();
+ for (var segment : segmente) {
+ var pm = EIN_ERSETZUNGS_PAAR.matcher(segment.strip());
+ if (!pm.matches()) {
+ return Optional.empty();
+ }
+ paareListe.add(new Paar(wortZitat(zitate, pm.group(1)), wortZitat(zitate, pm.group(2))));
+ }
+ var teile = new ArrayList<Aenderungsbefehl>();
+ for (var stelle : stellen) {
+ for (var paar : paareListe) {
+ teile.add(
+ new Ersetzung(
+ kontext.plus(stelle), paar.alt(), paar.neu(), jeweils, false, provenienz));
+ }
+ }
+ return Optional.of(teile.size() == 1 ? teile.get(0) : new Sammelbefehl(teile));
+ }
+
+ /**
+ * Verbundbefehl: mehrere per „und“ (bzw. „, wird/werden“) verkettete Einzelbefehle. Der Text wird
+ * an jeder Trennstelle probeweise gespalten; sobald beide Hälften als eigenständige Befehle
+ * erkannt werden, entsteht ein {@link Sammelbefehl}. Nur wenn <em>alle</em> Klauseln erkannt
+ * werden, greift die Zerlegung — sonst bleibt der Befehl unbekannt (konservativ).
+ */
+ private static Optional<Aenderungsbefehl> erkenneVerbund(
+ String text, Stelle kontext, ZitatExtraktor.Ergebnis zitate, Provenienz provenienz) {
+ var sep = VERBUND_SEP.matcher(text);
+ while (sep.find()) {
+ var links = text.substring(0, sep.start()).strip();
+ var rechts = text.substring(sep.end()).strip();
+ if (links.isEmpty() || rechts.isEmpty()) {
+ continue;
+ }
+ var linksBefehl = erkenneAlsSatz(links, kontext, zitate, provenienz);
+ if (linksBefehl.isEmpty()) {
+ continue;
+ }
+ var rechtsBefehl = erkenneRechteKlausel(links, rechts, kontext, zitate, provenienz);
+ if (rechtsBefehl.isEmpty()) {
+ continue;
+ }
+ var teile = new ArrayList<Aenderungsbefehl>();
+ flatten(linksBefehl.get(), teile);
+ flatten(rechtsBefehl.get(), teile);
+ return Optional.of(new Sammelbefehl(teile));
+ }
+ return Optional.empty();
+ }
+
+ /**
+ * Versucht die rechte Klausel eines Verbunds zu erkennen: (1) unverändert, (2) mit großem
+ * Anfangsbuchstaben (eigenständiger Befehl wie „nach …“ → „Nach …“), (3) mit vorangestelltem
+ * lokativem Präfix der linken Klausel („In <Stelle> “).
+ */
+ private static Optional<Aenderungsbefehl> erkenneRechteKlausel(
+ String links,
+ String rechts,
+ Stelle kontext,
+ ZitatExtraktor.Ergebnis zitate,
+ Provenienz provenienz) {
+ var direkt = erkenneAlsSatz(rechts, kontext, zitate, provenienz);
+ if (direkt.isPresent()) {
+ return direkt;
+ }
+ var gross = Character.toUpperCase(rechts.charAt(0)) + rechts.substring(1);
+ if (!gross.equals(rechts)) {
+ var alsBefehl = erkenneAlsSatz(gross, kontext, zitate, provenienz);
+ if (alsBefehl.isPresent()) {
+ return alsBefehl;
+ }
+ }
+ var praefix = lokativerPraefix(links);
+ if (praefix != null) {
+ return erkenneAlsSatz(praefix + " " + rechts, kontext, zitate, provenienz);
+ }
+ return Optional.empty();
+ }
+
+ private static Optional<Aenderungsbefehl> erkenneAlsSatz(
+ String klausel, Stelle kontext, ZitatExtraktor.Ergebnis zitate, Provenienz provenienz) {
+ var satz = klausel.endsWith(".") ? klausel : klausel + ".";
+ return erkenne(satz, kontext, zitate, provenienz);
+ }
+
+ /** Der Teil einer Klausel vor dem ersten „wird“/„werden“ („In § 3 Absatz 1“). */
+ private static @Nullable String lokativerPraefix(String klausel) {
+ var m = WIRD_WERDEN.matcher(klausel);
+ return m.find() ? klausel.substring(0, m.start()) : null;
+ }
+
+ private static void flatten(Aenderungsbefehl befehl, List<Aenderungsbefehl> ziel) {
+ if (befehl instanceof Sammelbefehl s) {
+ ziel.addAll(s.teilbefehle());
+ } else {
+ ziel.add(befehl);
+ }
+ }
+
+ /**
* Löst „Die bisherigen Absätze X bis Y werden zu den Absätzen X′ bis Y′.“ in einzelne
* Umnummerierungen auf, angewandt in absteigender Reihenfolge (Y→Y′ zuerst), damit die
* sequenzielle Anwendung keine Labels kollidieren lässt.
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java
index 07702b2..db7633e 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java
@@ -129,19 +129,27 @@
if (segment.isBlank()) {
return List.of();
}
- var teil = parse(segment);
- if (teil.isEmpty()) {
- return List.of();
- }
Stelle voll;
- if (vorige == null) {
- voll = teil.get();
- } else {
- var gemergt = mitGemeinsamemPraefix(vorige, teil.get());
- if (gemergt.isEmpty()) {
+ var teil = parse(segment);
+ if (teil.isPresent()) {
+ if (vorige == null) {
+ voll = teil.get();
+ } else {
+ var gemergt = mitGemeinsamemPraefix(vorige, teil.get());
+ if (gemergt.isEmpty()) {
+ return List.of();
+ }
+ voll = gemergt.get();
+ }
+ } else if (vorige != null && BLOSSES_LABEL.matcher(segment).matches()) {
+ // Bloße Nummer/Buchstabe („Absatz 1 und 5“ → das „5“): Typ der letzten Komponente erben.
+ var geerbt = mitGeerbtemLabel(vorige, segment);
+ if (geerbt.isEmpty()) {
return List.of();
}
- voll = gemergt.get();
+ voll = geerbt.get();
+ } else {
+ return List.of();
}
ergebnis.add(voll);
vorige = voll;
@@ -149,6 +157,29 @@
return ergebnis;
}
+ private static final Pattern BLOSSES_LABEL = Pattern.compile("\\d+[a-z]?|[a-z]{1,3}");
+
+ /** Ersetzt die letzte Komponente von {@code vorige} durch dieselbe Komponentenart mit neuem Label. */
+ private static Optional<Stelle> mitGeerbtemLabel(Stelle vorige, String label) {
+ var komponenten = new ArrayList<>(vorige.komponenten());
+ var letzte = komponenten.get(komponenten.size() - 1);
+ Stelle.Komponente neu =
+ switch (letzte) {
+ case Stelle.Paragraph p -> new Stelle.Paragraph(label);
+ case Stelle.AbsatzNr a -> new Stelle.AbsatzNr(label);
+ case Stelle.SatzNr s -> new Stelle.SatzNr(label);
+ case Stelle.NummerNr n -> new Stelle.NummerNr(label);
+ case Stelle.BuchstabeNr b -> new Stelle.BuchstabeNr(label);
+ case Stelle.Ueberschrift u -> null;
+ case Stelle.Inhaltsuebersicht i -> null;
+ };
+ if (neu == null) {
+ return Optional.empty();
+ }
+ komponenten.set(komponenten.size() - 1, neu);
+ return Optional.of(new Stelle(komponenten));
+ }
+
/**
* Ergänzt {@code segment} um die Präfix-Komponenten von {@code vorige}, die feiner-granular als
* die führende Komponente des Segments sind. Die führende Komponentenklasse des Segments wird in
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 05055e0..ba0dd05 100644
--- a/src/test/java/eu/mulk/aendggner/aenderung/parse/BefehlErkennerTest.java
+++ b/src/test/java/eu/mulk/aendggner/aenderung/parse/BefehlErkennerTest.java
@@ -412,32 +412,55 @@
}
@Test
- void verbundZweierBefehleBleibtUnbekannt() {
+ void verbundZweierBefehleWirdSammelbefehl() {
// „… wird zu Absatz 2 und nach Satz 2 werden … eingefügt“ verbindet zwei verschiedene
- // Befehle per „und“ — bewusst nicht unterstützt.
- assertThat(
- erkenne(
- "Der bisherige Absatz 1 wird zu Absatz 2 und nach Satz 2 werden die folgenden"
- + " Sätze eingefügt: „Ein Satz.“",
- Stelle.LEER))
- .isEmpty();
+ // Befehle per „und“ → Sammelbefehl aus Umnummerierung und Struktureinfügung.
+ var befehl =
+ erkenne(
+ "Der bisherige Absatz 1 wird zu Absatz 2 und nach Satz 2 werden die folgenden"
+ + " Sätze eingefügt: „Ein Satz.“",
+ Stelle.LEER);
+ var teile = ((Sammelbefehl) befehl.orElseThrow()).teilbefehle();
+ assertThat(teile).hasSize(2);
+ assertThat(teile.get(0)).isInstanceOf(Umnummerierung.class);
+ assertThat(teile.get(1)).isInstanceOf(StrukturEinfuegung.class);
}
@Test
- void faelltBeiBereichsbefehlenAufUnbekanntZurueck() {
- // Bereichs- und Mehrfachbefehle sind in v1 bewusst nicht unterstützt.
+ void koordinierteStelleErsetzungWirdSammelbefehl() {
+ // „In Absatz 1 Satz 1 und 2 …“ — koordinierte Stelle (das „2“ erbt „Satz“).
+ var befehl =
+ erkenne(
+ "In Absatz 1 Satz 1 und 2 werden die Wörter „alt“ durch die Wörter „neu“ ersetzt.",
+ Stelle.LEER);
+ var teile = ((Sammelbefehl) befehl.orElseThrow()).teilbefehle();
+ assertThat(teile).hasSize(2).allMatch(t -> t instanceof Ersetzung);
+ assertThat(teile).extracting(t -> t.stelle().anzeigeText())
+ .containsExactly("Absatz 1 Satz 1", "Absatz 1 Satz 2");
+ }
+
+ @Test
+ void faelltBeiBisBereichenAufUnbekanntZurueck() {
+ // „bis“-Bereiche über Struktureinheiten sind hier (Phase 1) noch nicht unterstützt.
assertThat(
erkenne(
"Die Absätze 2 bis 4 werden durch die folgenden Absätze 2 bis 6 ersetzt:"
+ " „(2) Text.“",
Stelle.LEER))
.isEmpty();
- assertThat(erkenne("Die Nummern 1 bis 3 werden aufgehoben.", Stelle.LEER)).isEmpty();
- assertThat(
- erkenne(
- "In Absatz 1 Satz 1 und 2 werden die Wörter „alt“ durch die Wörter „neu“"
- + " ersetzt.",
- Stelle.LEER))
- .isEmpty();
+ }
+
+ @Test
+ void mehrfachErsetzungWirdSammelbefehl() {
+ // Mehrere Ersetzungspaare unter einem gemeinsamen „ersetzt“.
+ var befehl =
+ erkenne(
+ "In Satz 1 werden die Wörter „a“ durch die Wörter „b“ und die Angabe „c“ durch die"
+ + " Wörter „d“ ersetzt.",
+ Stelle.LEER);
+ var teile = ((Sammelbefehl) befehl.orElseThrow()).teilbefehle();
+ assertThat(teile).hasSize(2).allMatch(t -> t instanceof Ersetzung);
+ assertThat(teile).extracting(t -> ((Ersetzung) t).alt()).containsExactly("a", "c");
+ assertThat(teile).extracting(t -> ((Ersetzung) t).neu()).containsExactly("b", "d");
}
}
diff --git a/src/test/java/eu/mulk/aendggner/aenderung/parse/StellenParserTest.java b/src/test/java/eu/mulk/aendggner/aenderung/parse/StellenParserTest.java
index 4c2c7ef..c0ea159 100644
--- a/src/test/java/eu/mulk/aendggner/aenderung/parse/StellenParserTest.java
+++ b/src/test/java/eu/mulk/aendggner/aenderung/parse/StellenParserTest.java
@@ -52,4 +52,19 @@
void unparsbaresSegmentLiefertLeereListe() {
assertThat(StellenParser.parseMehrfach("§ 5 Absatz 2 und Kauderwelsch")).isEmpty();
}
+
+ @Test
+ void bloßeNummerErbtKomponentenart() {
+ // „Absatz 1 und 5“: das „5“ erbt die Komponentenart „Absatz“ der letzten Komponente.
+ var stellen = StellenParser.parseMehrfach("§ 7 Absatz 1 und 5");
+ assertThat(stellen).extracting(Stelle::anzeigeText)
+ .containsExactly("§ 7 Absatz 1", "§ 7 Absatz 5");
+ }
+
+ @Test
+ void bloßeNummerNachSatz() {
+ var stellen = StellenParser.parseMehrfach("Absatz 1 Satz 1 und 2");
+ assertThat(stellen).extracting(Stelle::anzeigeText)
+ .containsExactly("Absatz 1 Satz 1", "Absatz 1 Satz 2");
+ }
}