"""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 datetime import datetime 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 abzugsdatum(pfad: Path) -> str: """Wann dieser Abzug gezogen wurde – das Datum der XML-Datei. **Warum die Dateizeit und nicht die Laufzeit.** Der Erzeuger liest die XML-Dateien, er holt sie nicht. Ein Datum aus dem Lauf sagte also nur, wann der Index neu gebaut wurde, und nicht, wie alt der Gesetzestext darin ist. Genau das war die Lücke: `content/gesetze/index.json` führte die Standangabe des Gesetzes („Zuletzt geändert durch …"), aber nirgends stand, wann jemand zuletzt nachgesehen hat, ob es dabei geblieben ist. Am 01.09.2026 lagen zwischen dem ältesten und dem jüngsten Abzug **sechs Jahre**. Bewusst kein Test, der mit dem Kalender rot wird: Eine Prüfung, die allein durch Zeitablauf ausfällt, ist am Tag ihres Ausfalls kein Befund, sondern eine Störung – dieselbe Bauart, die in `selbstsicherung.test.ts` schon einmal zugeschlagen hat. Sichtbar gemacht wird das Alter hier und in der Liste unter `docs/veroeffentlichen.md`; entscheiden muss es ein Mensch, denn nur er kann nachsehen, ob sich die Vorschrift geändert hat. """ return datetime.fromtimestamp(pfad.stat().st_mtime).date().isoformat() 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}/', 'abzug': abzugsdatum(pfad), '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())