"""Baut aus den amtlichen Gesetzes-XML einen prüfbaren Fundstellenindex. Warum das sein muss ------------------- Die Erklärungstexte zu den 575 Prüfungsfragen nennen Fundstellen wie „§ 12 Abs. 1 Nr. 3 Buchst. b WaffG". Eine erfundene oder verschobene Fundstelle ist in diesem Fach kein Schönheitsfehler: Wer sich darauf verlässt, lernt geltendes Recht falsch. Verlassen wird sich deshalb nicht auf Sorgfalt, sondern auf eine maschinelle Prüfung – dieser Index ist ihre Grundlage. Quelle ------ Ausschließlich das amtliche XML von gesetze-im-internet.de, herausgegeben vom Bundesministerium der Justiz gemeinsam mit der juris GmbH. Die Dateien liegen unverändert unter `content/gesetze/`; dieses Skript liest sie und schreibt `content/gesetze/index.json`. Gesetzestexte sind nach § 5 Abs. 1 UrhG gemeinfrei. Sie werden nicht mit der Anwendung ausgeliefert – der Index dient allein der Prüfung im Entwicklungsbaum. Gliederung ---------- Das Gesetzes-XML gliedert Aufzählungen als `
1.
`, beliebig tief verschachtelt („1." enthält „a)" enthält „aa)"). Der Index bildet diese Struktur ab, damit sich eine Fundstelle bis auf die Buchstabenebene prüfen lässt. Anlagen (etwa die Begriffsbestimmungen in Anlage 1 WaffG) folgen einer eigenen Systematik mit Abschnitten und Unterabschnitten. Für sie wird nur der Volltext abgelegt; die Prüfung arbeitet dort mit Textsuche statt mit Struktur. Aufruf ------ python data-pipeline/gesetze_index.py """ from __future__ import annotations import io import json import re import xml.etree.ElementTree as ET from dataclasses import dataclass, field from pathlib import Path WURZEL = Path(__file__).resolve().parent.parent GESETZE = WURZEL / 'content' / 'gesetze' ZIEL = GESETZE / 'index.json' # Kürzel -> (Dateiname, Bezeichnung, Kennung bei gesetze-im-internet.de) QUELLEN: dict[str, tuple[str, str, str]] = { 'WaffG': ('BJNR397010002.xml', 'Waffengesetz', 'waffg_2002'), 'AWaffV': ('BJNR212300003.xml', 'Allgemeine Waffengesetz-Verordnung', 'awaffv'), 'BeschG': ('BJNR400300002.xml', 'Beschussgesetz', 'beschg'), 'BeschussV': ('BJNR147400006.xml', 'Beschussverordnung', 'beschussv'), 'StGB': ('BJNR001270871.xml', 'Strafgesetzbuch', 'stgb'), 'SprengG': ('BJNR027370976.xml', 'Sprengstoffgesetz', 'sprengg_1976'), '1. SprengV': ('BJNR021410977.xml', 'Erste Verordnung zum Sprengstoffgesetz', 'sprengv_1'), } #: „(1)" oder „(1a)" am Absatzanfang. ABSATZ_MUSTER = re.compile(r'^\(\s*(\d+[a-z]?)\s*\)\s*', re.UNICODE) #: Eine Nummernmarke: „1", „12", „3a". Ein blosser Buchstabe ist keine. NUMMER_MARKE = re.compile(r'^\d+[a-z]?$', re.UNICODE) @dataclass class Absatz: nummer: str text: str #: Nummer -> darin enthaltene Buchstaben. Leer, wenn nicht gegliedert. gliederung: dict[str, list[str]] = field(default_factory=dict) @dataclass class Norm: """Ein Paragraf oder eine Anlage.""" bezeichnung: str # „§ 12" oder „Anlage 1" titel: str text: str absaetze: dict[str, Absatz] = field(default_factory=dict) ist_anlage: bool = False def text_von(element: ET.Element | None) -> str: """Fließtext eines Elements mit lesbaren Zeilenumbrüchen.""" if element is None: return '' teile: list[str] = [] def sammeln(knoten: ET.Element) -> None: #
trägt die Gliederungsmarke („1.", „a)") und beginnt eine Zeile. if knoten.tag in ('BR', 'DT'): teile.append('\n') if knoten.text: teile.append(knoten.text) for kind in knoten: sammeln(kind) if kind.tail: teile.append(kind.tail) sammeln(element) roh = ''.join(teile).replace('­', '').replace(' ', ' ') zeilen = [z.strip() for z in roh.split('\n')] return '\n'.join(z for z in zeilen if z) def direkte_listen(element: ET.Element) -> list[ET.Element]: """Die obersten `
` unterhalb von `element`. Sobald eine Liste gefunden ist, wird nicht weiter hineingestiegen: Ihre Untergliederung gehört zu ihr, nicht zur Ebene darüber. """ gefunden: list[ET.Element] = [] def gehe(knoten: ET.Element) -> None: for kind in knoten: if kind.tag == 'DL': gefunden.append(kind) else: gehe(kind) gehe(element) return gefunden # Eine brauchbare Gliederungsmarke: Ziffer mit optionalem Kleinbuchstaben # (»1«, »3a«) oder ein bis zwei Kleinbuchstaben (»a«, »aa«). MARKE_MUSTER = re.compile(r'^(?:\d{1,3}[a-z]?|[a-z]{1,2})$') # Was beim Lesen des amtlichen XML nicht aufging. Wird von `main()` gemeldet: # Ein Absatz mit unlesbarer Gliederung fällt sonst niemandem auf, und an ihm # scheitern anschliessend richtige Zitate. MARKENBEFUNDE: list[str] = [] def liste_lesen(dl: ET.Element, ort: str = '') -> list[tuple[str, list[str]]]: """Eine `
` als [(Marke, Marken der Untergliederung)]. ## Warum Marken geprüft werden Das amtliche XML ist nicht überall sauber. Gemessen an der ausgelieferten Fassung: `BeschG § 11 Abs. 3` trägt ein leeres `
`, weil die »1.« versehentlich **vor** der Liste im Absatztext steht (»wenn % 1.«); ebenso `StGB § 46 Abs. 2`. `BeschussV § 39 Abs. 2` liefert »-«, `BeschG § 2 Abs. 7` ein »*«. Bis Fassung 0.24.1 wanderten diese Marken ungeprüft in den Index. Zwei Folgen: `gesetz.py --gliederung` stürzte an der leeren Marke ab, und die Fundstellenprüfung wies **richtige** Zitate ab – »§ 11 Abs. 3 Nr. 1 BeschG« gibt es, der Index kannte die Nummer aber als Leerzeichenkette. Eine leere Marke wird deshalb aus ihrer **Stelle** in der Liste hergeleitet, aber nur, wenn das eindeutig ist: Die Liste muss aufsteigend numerisch sein und die hergeleitete Nummer darf noch nicht vorkommen. Jeder Fall wird gemeldet – geraten wird hier nichts stumm. """ eintraege: list[tuple[str, list[str]]] = [] marke: str | None = None stelle = 0 for kind in dl: if kind.tag == 'DT': marke = ''.join(kind.itertext()).strip().rstrip('.)') elif kind.tag == 'DD' and marke is not None: stelle += 1 unter: list[str] = [] for tiefer in direkte_listen(kind): unter.extend(m for m, _ in liste_lesen(tiefer, ort)) # Anlagen bleiben aussen vor: Fuer sie legt der Index nur den # Volltext ab (siehe Modulkopf), ihre Gliederung wird verworfen. # Sie zaehlen dutzendweise Punkte wie »3.4.1« und Kuerzel wie # »SW« auf – als Befund waere das nur Rauschen, das die neun # echten Faelle in den Paragrafen zudeckt. if not MARKE_MUSTER.match(marke) and not ort.lower().startswith('anlage'): ersatz = str(stelle) schon = {m for m, _ in eintraege} if not marke and ersatz not in schon: MARKENBEFUNDE.append( f'{ort}: leere Gliederungsmarke an Stelle {stelle} – ' f'als »{ersatz}« gefuehrt (amtliches XML unvollstaendig)' ) marke = ersatz else: MARKENBEFUNDE.append( f'{ort}: unbrauchbare Gliederungsmarke {marke!r} an Stelle ' f'{stelle} – nicht in den Index aufgenommen' ) marke = None continue eintraege.append((marke, unter)) marke = None return eintraege def falten(gliederung: dict[str, list[str]]) -> dict[str, list[str]]: """Schlägt Buchstaben auf Nummernebene der vorangehenden Nummer zu. Das Gesetzes-XML setzt Untergliederungen nicht immer in das `
` ihrer Nummer; in § 12 Abs. 4 WaffG etwa stehen die Buchstaben zu Nr. 3 als eigene Liste daneben. Ohne diese Korrektur entstünde daraus eine „Nr. a", die es nicht gibt – und eine Fundstelle darauf käme durch die Prüfung. Steht dagegen gar keine Nummer voran, ist die Buchstabengliederung echt: § 3a Abs. 2 SprengG zählt unmittelbar mit „a)" und „b)" auf. """ ergebnis: dict[str, list[str]] = {} letzte_nummer: str | None = None for marke, unter in gliederung.items(): if NUMMER_MARKE.match(marke): ergebnis[marke] = list(unter) letzte_nummer = marke elif letzte_nummer is not None: ergebnis[letzte_nummer].append(marke) ergebnis[letzte_nummer].extend(unter) else: ergebnis[marke] = list(unter) return ergebnis def gliederung_von(element: ET.Element, ort: str = '') -> dict[str, list[str]]: """Gliederung eines Absatz-Blocks als Marke -> Untermarken.""" gliederung: dict[str, list[str]] = {} for dl in direkte_listen(element): for marke, unter in liste_lesen(dl, ort): # Bei mehreren Listen im selben Absatz gewinnt die erste Nennung; # doppelte Marken kommen im Gesetzestext nicht vor. gliederung.setdefault(marke, unter) return falten(gliederung) def absaetze_lesen(bloecke: list[tuple[str, dict[str, list[str]]]]) -> dict[str, Absatz]: """Ordnet die `

`-Blöcke ihren Absatznummern zu. Ein Absatz kann sich über mehrere `

` erstrecken. Ein Block ohne eigene Nummer gehört deshalb zum zuletzt begonnenen Absatz. """ absaetze: dict[str, Absatz] = {} laufend: str | None = None for text, gliederung in bloecke: treffer = ABSATZ_MUSTER.match(text) if treffer: laufend = treffer.group(1) absaetze[laufend] = Absatz( nummer=laufend, text=text[treffer.end() :], gliederung=dict(gliederung), ) elif laufend is not None: absaetze[laufend].text += '\n' + text for marke, unter in gliederung.items(): absaetze[laufend].gliederung.setdefault(marke, unter) return absaetze def norm_lesen(norm: ET.Element) -> Norm | None: md = norm.find('metadaten') if md is None: return None bezeichnung = (md.findtext('enbez') or '').strip() if not bezeichnung: return None inhalt = norm.find('textdaten/text/Content') bloecke: list[tuple[str, dict[str, list[str]]]] = [] if inhalt is not None: for block in inhalt: text = text_von(block) if text: bloecke.append((text, gliederung_von(block, bezeichnung))) ist_anlage = bezeichnung.lower().startswith('anlage') return Norm( bezeichnung=bezeichnung, titel=(md.findtext('titel') or '').strip(), text='\n'.join(t for t, _ in bloecke), absaetze={} if ist_anlage else absaetze_lesen(bloecke), ist_anlage=ist_anlage, ) def gesetz_lesen(pfad: Path) -> tuple[dict[str, str], dict[str, Norm]]: wurzel = ET.parse(pfad).getroot() kopf: dict[str, str] = {} normen: dict[str, Norm] = {} for norm_element in wurzel.findall('norm'): md = norm_element.find('metadaten') if md is None: continue # Der erste Datensatz trägt die Angaben zum Gesetz als Ganzes. if not kopf: stand = md.find('standangabe/standkommentar') kopf = { 'jurabk': (md.findtext('jurabk') or '').strip(), 'amtabk': (md.findtext('amtabk') or '').strip(), 'langtitel': (md.findtext('langue') or '').strip(), 'ausfertigung': (md.findtext('ausfertigung-datum') or '').strip(), 'stand': (stand.text or '').strip() if stand is not None else '', } norm = norm_lesen(norm_element) if norm is not None and (norm.bezeichnung.startswith('§') or norm.ist_anlage): normen[norm.bezeichnung] = norm return kopf, normen def main() -> int: gesetze: dict[str, object] = {} for kuerzel, (datei, bezeichnung, kennung) in QUELLEN.items(): pfad = GESETZE / datei if not pfad.exists(): print(f'FEHLT: {kuerzel} ({datei})') return 1 kopf, normen = gesetz_lesen(pfad) paragrafen = {b: n for b, n in normen.items() if not n.ist_anlage} anlagen = {b: n for b, n in normen.items() if n.ist_anlage} gesetze[kuerzel] = { 'bezeichnung': bezeichnung, 'langtitel': kopf.get('langtitel', ''), 'stand': kopf.get('stand', ''), 'ausfertigung': kopf.get('ausfertigung', ''), 'quelle': f'https://www.gesetze-im-internet.de/{kennung}/', 'normen': { b: { 'titel': n.titel, 'ist_anlage': n.ist_anlage, 'absaetze': { a.nummer: {'gliederung': a.gliederung, 'text': a.text} for a in n.absaetze.values() }, 'text': n.text, } for b, n in normen.items() }, } gegliedert = sum( 1 for n in paragrafen.values() for a in n.absaetze.values() if a.gliederung ) print( f'{kuerzel:10} {len(paragrafen):4} Paragrafen, {len(anlagen)} Anlagen, ' f'{gegliedert:4} gegliederte Absaetze' ) ergebnis = { 'hinweis': ( 'Maschinell erzeugt aus dem amtlichen XML von gesetze-im-internet.de ' '(Bundesministerium der Justiz / juris GmbH). Nicht von Hand aendern - ' 'neu erzeugen mit: python data-pipeline/gesetze_index.py' ), 'gesetze': gesetze, } with io.open(ZIEL, 'w', encoding='utf-8', newline='\n') as datei_aus: json.dump(ergebnis, datei_aus, ensure_ascii=False, indent=1) print(f'\ngeschrieben: {ZIEL.name} ({ZIEL.stat().st_size / 1_048_576:.1f} MB)') # Was beim Lesen nicht aufging, gehört ausgesprochen. Ein Absatz mit # unlesbarer Gliederung fällt sonst niemandem auf – und an ihm scheitern # anschliessend richtige Zitate. if MARKENBEFUNDE: print(f'\nUnsaubere Gliederungsmarken im amtlichen XML ({len(MARKENBEFUNDE)}):') for befund in MARKENBEFUNDE: print(f' {befund}') return 0 if __name__ == '__main__': raise SystemExit(main())