diff options
Diffstat (limited to 'src/main/java/eu')
| -rw-r--r-- | src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java | 194 | ||||
| -rw-r--r-- | src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java | 51 |
2 files changed, 234 insertions, 11 deletions
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 @@ final class BefehlErkenner { + "|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 @@ final class BefehlErkenner { } /** - * 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 @@ final class BefehlErkenner { */ 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 @@ final class BefehlErkenner { 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 @@ final class BefehlErkenner { } /** + * „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 @@ public final class StellenParser { 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 @@ public final class StellenParser { 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 |
