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");
+  }
 }