aboutsummaryrefslogtreecommitdiff
path: root/src/main
diff options
context:
space:
mode:
authorMatthias Andreas Benkard <code@mail.matthias.benkard.de>2026-08-02 20:01:37 +0200
committerMatthias Andreas Benkard <code@mail.matthias.benkard.de>2026-08-02 20:01:37 +0200
commit5b2e6ef635026a62e23fe5ea6df2c85d839ff78c (patch)
tree6c7af175312d929ea698a6567ba8f2fd3367abc4 /src/main
parenta201e83e59562e0e662196a616ff746cdaddd74e (diff)
Add Hesse as an extraction and recognition case
The Hesse GVBl issue 5/2026 amends the ordinance on traffic-law competences. All 21 commands of its article 1 are now recognised. The blocked letterspacing that was expected to be the hard part turns out to affect only the signature block, not a single command. What did need work was three other things: the running GVBl footer and the masthead remainder are now stripped as page furniture, and so is the heading's asterisk footnote ("* Ändert FFN 61-60"), which sits at the foot of a page between two enumeration items and stuck to the command above it. Two command forms are new, both general rather than Hessian: "am Ende" may be absent from a punctuation replacement — the definite article then requires the unit to carry exactly one such mark, which the applier checks — and the object after "durch" may be elided in the form without a location, as it already could in the form with one. Where a punctuation replacement follows a renumbering in the same sentence it binds to the renumbered unit: unlike a word operation it has no distinguishing target text, so resolving it norm-wide would be useless. A full acceptance test stays out of reach: hessenrecht.hessen.de is the same login-only juris application as the Schleswig-Holstein and Berlin portals. Checking landesrecht-bw.de too, that now covers all four portals that could have supplied an annex-bearing statute, so the LandesRechtTextParser's missing annex headings will need a stem from recht.nrw.de instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Change-Id: Ief04164ee25653345a011f3ffbbfaa6df1f5c64b
Diffstat (limited to 'src/main')
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java63
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java25
-rw-r--r--src/main/resources/sampledata/Landesrecht-Beispiele.adoc48
3 files changed, 113 insertions, 23 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 f83ce2d..966ab5d 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java
@@ -123,13 +123,15 @@ final class BefehlErkenner {
+ Z
+ " ersetzt\\.$");
+ // Wie ERSETZUNG, nur ohne Fundstelle — die liefert der Kontextrahmen. Das Objekt nach „durch“
+ // darf auch hier verkürzt sein („Die Angabe „X“ wird durch „Y“ ersetzt“, hessisches GVBl).
private static final Pattern ERSETZUNG_OHNE_STELLE =
Pattern.compile(
"^(?:Die Wörter|Das Wort|Die Angabe|Die Zahl) "
+ Z
- + " (?:wird|werden) (jeweils )?durch "
+ + " (?:wird|werden) (jeweils )?durch (?:"
+ WOERTER
- + " "
+ + " )?"
+ Z
+ " ersetzt\\.$");
@@ -174,10 +176,13 @@ final class BefehlErkenner {
+ "\\.?$");
// Ohne Stellenangabe: „Der Punkt am Ende wird durch die Angabe „…“ ersetzt.“ — das Satzzeichen ist
- // hier selbst das Subjekt, die Fundstelle liefert der Kontextrahmen.
+ // hier selbst das Subjekt, die Fundstelle liefert der Kontextrahmen. Der Zusatz „am Ende“ darf
+ // fehlen („Das Komma wird durch das Wort „und“ ersetzt.“, hessisches GVBl): der bestimmte Artikel
+ // setzt dann voraus, dass die Einheit genau ein solches Satzzeichen trägt — was der Anwender
+ // prüft, statt es an das Einheitsende zu heften.
private static final Pattern SATZZEICHEN_ERSETZUNG_OHNE_STELLE =
Pattern.compile(
- "^(Der Punkt|Das Komma|Das Semikolon) am Ende(?: des Satzes)? wird durch "
+ "^(Der Punkt|Das Komma|Das Semikolon)(?: am Ende(?: des Satzes)?)? wird durch "
+ "(ein Komma und "
+ WOERTER
+ " "
@@ -188,11 +193,12 @@ final class BefehlErkenner {
+ Z
+ ") ersetzt\\.$");
- // Auch die Verbundform „wird der Punkt am Ende durch ein Komma und die Wörter „…“ ersetzt“.
+ // Auch die Verbundform „wird der Punkt am Ende durch ein Komma und die Wörter „…“ ersetzt“;
+ // „am Ende“ ist wie oben entbehrlich.
private static final Pattern SATZZEICHEN_ERSETZUNG =
Pattern.compile(
"^(?:In )?(.+?) (?:wird|werden) (der Punkt|das Komma|das Semikolon)"
- + " am Ende(?: des Satzes)? durch "
+ + "(?: am Ende(?: des Satzes)?)? durch "
+ "(ein Komma und "
+ WOERTER
+ " "
@@ -1014,7 +1020,7 @@ final class BefehlErkenner {
} else {
neu = satzzeichen(m.group(2));
}
- return Optional.of(new Ersetzung(kontext, alt, neu, false, true, provenienz));
+ return Optional.of(new Ersetzung(kontext, alt, neu, false, amEnde(text), provenienz));
}
if ((m = SATZZEICHEN_ERSETZUNG.matcher(text)).matches()) {
@@ -1029,8 +1035,9 @@ final class BefehlErkenner {
neu = satzzeichen(m.group(3));
}
var neuText = neu;
+ var amEnde = amEnde(text);
return StellenParser.parse(m.group(1))
- .map(s -> new Ersetzung(kontext.plus(s), alt, neuText, false, true, provenienz));
+ .map(s -> new Ersetzung(kontext.plus(s), alt, neuText, false, amEnde, provenienz));
}
if ((m = WOERTER_EINFUEGUNG.matcher(text)).matches()) {
@@ -1519,6 +1526,10 @@ final class BefehlErkenner {
"(?i)(?:die Angabe|nach der Angabe|vor der Angabe|die Wörter|die Worte|"
+ "nach den Wörtern|vor den Wörtern|das Wort)\\b");
+ // Klausel, deren Subjekt ein Satzzeichen ist („das Komma wird durch … ersetzt“).
+ private static final Pattern SATZZEICHEN_SUBJEKT =
+ Pattern.compile("(?i)(?:der Punkt|das Komma|das Semikolon)\\b");
+
private static Optional<Aenderungsbefehl> erkenneVerbund(
String text, Stelle kontext, ZitatExtraktor.Ergebnis zitate, Provenienz provenienz) {
var sep = VERBUND_SEP.matcher(text);
@@ -1585,6 +1596,31 @@ final class BefehlErkenner {
return imNeuen;
}
}
+ // „Die bisherige Nr. 7 wird Nr. 5 und das Komma wird durch das Wort „und“ ersetzt.“ — anders
+ // als eine Wortoperation trägt eine Satzzeichen-Operation keinen unterscheidenden Zieltext:
+ // norm-weit aufgelöst träfe „das Komma“ auf beliebig viele Vorkommen. Sie meint deshalb stets
+ // die soeben umnummerierte Einheit und wird auf diese festgelegt.
+ if (linksBefehl instanceof Umnummerierung umnummerierung
+ && SATZZEICHEN_SUBJEKT.matcher(rechts).lookingAt()) {
+ var relativ = relativeStelle(umnummerierung.neu(), kontext);
+ var vm = WIRD_WERDEN.matcher(rechts);
+ if (!relativ.istLeer() && vm.find()) {
+ // Grammatische Normalform wie beim Gapping: Lokativ voran, Verb vor das Subjekt.
+ var umgestellt =
+ "In "
+ + relativ.anzeigeText()
+ + " "
+ + vm.group().strip()
+ + " "
+ + rechts.substring(0, vm.start()).strip()
+ + " "
+ + rechts.substring(vm.end()).strip();
+ var imNeuen = erkenneAlsSatz(umgestellt, kontext, zitate, provenienz);
+ if (imNeuen.isPresent()) {
+ return imNeuen;
+ }
+ }
+ }
// „Der bisherige Wortlaut wird Abs. 5 und in Halbsatz 1 wird … ersetzt“ (bayerisch): die
// lokative Folgeklausel bezieht sich auf den soeben nummerierten Absatz.
if (linksBefehl instanceof WortlautZuAbsatz wz
@@ -2074,6 +2110,17 @@ final class BefehlErkenner {
return zitat(zitate, index).replaceAll("\\s+", " ").strip();
}
+ /**
+ * Nennt der Befehl das Satzzeichen ausdrücklich „am Ende“, so ist es dort zu ersetzen; sonst
+ * verlangt der bestimmte Artikel, dass die Einheit genau eines trägt — das prüft der Anwender.
+ * Zitate sind hier bereits maskiert, die Wendung kann also nicht aus dem Ersatztext stammen.
+ */
+ private static boolean amEnde(String text) {
+ return AM_ENDE.matcher(text).find();
+ }
+
+ private static final Pattern AM_ENDE = Pattern.compile("\\bam Ende\\b");
+
private static String satzzeichen(String phrase) {
// Am Satzanfang steht dieselbe Phrase großgeschrieben („Der Punkt am Ende wird …“).
return switch (Character.toLowerCase(phrase.charAt(0)) + phrase.substring(1)) {
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 3945df9..483debc 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java
@@ -146,6 +146,26 @@ public final class TextBereiniger {
Pattern.compile(
"^\\s*(?:Nummer \\d{4}/\\d{1,3}|\\d{4}/\\d{1,3} vom \\d{1,2}\\. \\p{L}+)\\s*$");
+ // GVBl. für das Land Hessen: laufende Fußzeile („Gesetz- und Verordnungsblatt für das Land
+ // Hessen ­ Nr. 5 vom 3. Februar 2026 2“) und der Rest des Titelkopfs („für das Land Hessen“,
+ // „2026 Wiesbaden, den 3. Februar 2026 Nr. 5“). Die Fußzeile steht zwischen zwei
+ // Gliederungspunkten und hängte sich sonst an den vorangehenden Befehl. Der Trenner ist ein
+ // weiches Trennzeichen (U+00AD), kein Gedankenstrich.
+ private static final Pattern GVBL_HESSEN_FUSS =
+ Pattern.compile(
+ "^\\s*Gesetz- und Verordnungsblatt für das Land Hessen\\s*[\\u00ad–-]\\s*Nr\\. \\d+"
+ + " vom .+? \\d{4}\\s+\\d{1,4}\\s*$");
+ private static final Pattern GVBL_HESSEN_KOPF =
+ Pattern.compile(
+ "^\\s*(?:für das Land Hessen|\\d{4}\\s+\\p{Lu}\\p{L}+,\\s+den .+? \\d{4}\\s+Nr\\. \\d+)"
+ + "\\s*$");
+
+ // Amtliche Sternchen-Fußnote der Verordnungs-/Gesetzesüberschrift, die auf die Fundstellennummer
+ // des geänderten Rechts verweist („* Ändert FFN 61-60“, Hessen; „* Ändert Gl.Nr. …“). Sie steht
+ // am Seitenfuß mitten zwischen zwei Gliederungspunkten und ist damit funktional Seitenmöbel.
+ private static final Pattern FUNDSTELLEN_FUSSNOTE =
+ Pattern.compile("^\\s*\\*\\s*Ändert (?:FFN|Gl\\.\\s?Nr\\.|GVBl\\.).*$");
+
/** Konjunktionen, die typischerweise auf einen Suspensivstrich folgen („Wirk- und …“). */
private static final Pattern KONJUNKTION =
Pattern.compile("^(und|oder|sowie|bzw\\.|beziehungsweise)\\b.*");
@@ -372,7 +392,10 @@ public final class TextBereiniger {
|| NDS_HERAUSGEBER.matcher(zeile).matches()
|| GVOBL_SH_KOPF.matcher(zeile).matches()
|| GVOBL_SH_LAND.matcher(zeile).matches()
- || GVOBL_SH_HEFT.matcher(zeile).matches();
+ || GVOBL_SH_HEFT.matcher(zeile).matches()
+ || GVBL_HESSEN_FUSS.matcher(zeile).matches()
+ || GVBL_HESSEN_KOPF.matcher(zeile).matches()
+ || FUNDSTELLEN_FUSSNOTE.matcher(zeile).matches();
}
/**
diff --git a/src/main/resources/sampledata/Landesrecht-Beispiele.adoc b/src/main/resources/sampledata/Landesrecht-Beispiele.adoc
index 45c4e4f..be70dfc 100644
--- a/src/main/resources/sampledata/Landesrecht-Beispiele.adoc
+++ b/src/main/resources/sampledata/Landesrecht-Beispiele.adoc
@@ -44,7 +44,9 @@ gefasst"); *hochgestellte Fußnotenmarker an Artikel-Überschriften* („Artikel
03.02.2026 (Elfte Verordnung zur Änderung der Verordnung zur Bestimmung verkehrsrechtlicher
Zuständigkeiten)
|*Sperrsatz* in Datums- und Signaturzeilen („3 . F e bru a r 2 02 6", „Der Mi n is t er pr
-äs i d e nt") als Extraktions-Härtetest; *abgekürztes „Abs."/„Nr." im §-Kontext*; FFN-Fußnote.
+äs i d e nt") — er trifft, anders als erwartet, nur den Unterschriftenblock und keinen Befehl;
+*abgekürztes „Abs."/„Nr." im §-Kontext*; die *FFN-Fußnote der Überschrift* („* Ändert FFN 61-60")
+steht am Seitenfuß mitten zwischen zwei Gliederungspunkten.
|Niedersachsen
|`Niedersachsen/Nds-GVBl-2026-10_ELER-Foerdergesetz-AendG.pdf` — Nds. GVBl. 2026 Nr. 10 vom
@@ -133,6 +135,13 @@ vollständig belegt.
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“).
+* *Hessen* ist bis zur Befehlserkennung umgesetzt (`EndToEndTest.verkehrsZustVAendVOHessen`): alle
+ 21 Befehle des Artikels 1 werden erkannt. Der als Härtetest erwartete Sperrsatz erwies sich als
+ harmlos — er trifft nur den Unterschriftenblock. Zu tun war dreierlei: die laufende GVBl-Fußzeile
+ und der Masthead als Kolumnentitel, die Sternchen-Fußnote der Überschrift („* Ändert FFN 61-60“,
+ am Seitenfuß zwischen zwei Gliederungspunkten) ebenso, und zwei Befehlsformen — der Zusatz
+ „am Ende“ darf beim Satzzeichen fehlen, und das Objekt hinter „durch“ darf auch ohne Fundstelle
+ verkürzt sein. Ein voller Akzeptanztest ist *nicht* möglich — 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.
@@ -154,12 +163,14 @@ Vorfassung kommt über die Wayback-CDX-API: Fassungs-IDs abfragen, die zum Zeitr
per `…id_/…`-Rohform laden. Achtung, `WebFetch` erreicht `web.archive.org` nicht — nur `curl`.
juris-Landesportale:: Sackgasse — *geprüft* für `gesetze-rechtsprechung.sh.juris.de`
-(Schleswig-Holstein) und `gesetze.berlin.de` (Berlin, trotz Eigennamen dieselbe Anwendung). Beide
-sind seit etwa Ende 2022 reine Single-Page-Anwendungen; sämtliche Dokument-URLs liefern nur die
-leere Anwendungshülle, die Inhalte hängen an einer tokenpflichtigen Schnittstelle. Auch archivierte
+(Schleswig-Holstein), `gesetze.berlin.de` (Berlin, trotz Eigennamen dieselbe Anwendung),
+`landesrecht-bw.de` (Baden-Württemberg) und `rv.hessenrecht.hessen.de` (Hessen, Kennung `bshe`).
+Alle vier sind seit etwa Ende 2022 reine Single-Page-Anwendungen; sämtliche Dokument-URLs liefern
+nur die leere Anwendungshülle (rund 5,4 kB), die Inhalte hängen an einer tokenpflichtigen
+Schnittstelle. Auch archivierte
Einzelnorm-Seiten sind bereits Hüllen. Brauchbar sind allenfalls Wayback-Snapshots aus der Zeit
*vor* der Umstellung — die sind aber meist zu alt für die gesuchte Fassung. Für
-`landesrecht-bw.de`, `hessenrecht.hessen.de`, `landesrecht.thueringen.de` und `voris.niedersachsen.de`
+`landesrecht.thueringen.de` und `voris.niedersachsen.de`
ist dasselbe zu erwarten, aber *nicht* nachgeprüft: Ein Versuch lohnt, bevor man aufgibt.
Fällt das Portal aus, bleibt der Weg über das Gesetzblatt selbst: Trägt der Einleitungssatz des
@@ -189,12 +200,16 @@ Klartext-Zweig des Loaders normalisiert zur Sicherheit mit.
juris-Anwendung wie das schleswig-holsteinische Portal (Kennung `bsbe`) und ebenso
anmeldepflichtig. In der Wayback Machine gibt es keine serverseitig gerenderten Volltexte des
ASOG oder des LAF-Errichtungsgesetzes.
+* *Hessen — Stammfassung nicht beschaffbar*: `rv.hessenrecht.hessen.de` ist dieselbe juris-Anwendung
+ (Kennung `bshe`) wie die Portale Schleswig-Holsteins und Berlins; Dokument-URLs liefern nur die
+ Anwendungshülle. Die Befehlserkennung ist vollständig belegt, die Anwendung bleibt offen.
* *Anlagen im kanonischen Klartext*: Anlagen sind als Änderungsziel unterstützt — im gii-XML sind
sie eigene Normen („Anlage 8“, „Anhang“), und `Stelle.anlagenEnbez()` löst sie auf; im GEG werden
anlagenbezogene Befehle angewandt. Der `LandesRechtTextParser` kennt jedoch nur „§“- und
„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.
+ konkrete Lücke für Berlin (Artikel 1) und Baden-Württemberg (Anlagen 1/3b/4b). Beide Portale sind
+ inzwischen als juris-Anwendung *nachgeprüft* — die Lücke geht deshalb erst mit einem
+ Anlage-tragenden Stammgesetz aus einem anderen Portal auf, praktisch `recht.nrw.de`.
* *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.
@@ -208,15 +223,20 @@ Klartext-Zweig des Loaders normalisiert zur Sicherheit mit.
Kein Land scheitert noch an reiner Extraktionsschwäche; offen sind Modell- und Beschaffungsfragen.
Nach absteigendem Nutzen:
-. *Anlagen-Normköpfe im `LandesRechtTextParser`* — der Parser kennt jetzt neben „§“/„Art.“ auch
- die Inhaltsübersicht als Norm; die Anlage fehlt noch. Lohnt, 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.
+. *Ein Anlage-tragendes Stammgesetz über `recht.nrw.de` beschaffen* — und dann die
+ *Anlagen-Normköpfe im `LandesRechtTextParser`* ergänzen (er kennt neben „§“/„Art.“ inzwischen
+ auch die Inhaltsübersicht als Norm). Über Berlin oder Baden-Württemberg geht das nicht mehr:
+ deren Portale sind als juris-Anwendung nachgeprüft. Ohne eine solche Stammfassung bleibt die
+ Ergänzung nicht end-to-end prüfbar.
+. *Thüringen* (`Thueringen/GVBl-TH-2026-02_…`) ist als einziges beschafftes Heft noch gar nicht
+ angefasst — ein Sammelheft mit mehreren Verkündungen, also zugleich ein Test der Artikel-/
+ Verkündungsauswahl.
+. *Berlin, ganzseitenbreite Rahmen* (XY-Cut) — die letzte offene Layout-Frage, siehe „Noch offen“.
Regressionsschutz: `./mvnw verify` prüft die bestehenden Belegfälle mit. Gepinnt ist dabei nicht
überall dieselbe Art von Zahl — für UWG, AGG und ProdHaftG, dass *kein* Befehl manuell bleibt; für
das BayJG die 154 erkannten Befehle und namentlich die drei Umnummerierungs-Residuen; für Sachsen,
-Niedersachsen und die drei NRW-Artikel die vollständige Anwendung bis auf das eine benannte
-Residuum im WDR-Gesetz. Wer eine dieser Zahlen
+Niedersachsen und die vier NRW-Artikel die vollständige Anwendung bis auf das eine benannte
+Residuum im WDR-Gesetz; für Schleswig-Holstein, Berlin und Hessen die vollständige *Erkennung*
+(dort 21 Befehle). Wer eine dieser Zahlen
ändern muss, sollte den Grund im Test vermerken.