waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
/ data-pipeline gesetze_index.py
| 1 | """Baut aus den amtlichen Gesetzes-XML einen prüfbaren Fundstellenindex. |
| 2 | |
| 3 | Warum das sein muss |
| 4 | ------------------- |
| 5 | Die Erklärungstexte zu den 575 Prüfungsfragen nennen Fundstellen wie |
| 6 | „§ 12 Abs. 1 Nr. 3 Buchst. b WaffG". Eine erfundene oder verschobene |
| 7 | Fundstelle ist in diesem Fach kein Schönheitsfehler: Wer sich darauf verlässt, |
| 8 | lernt geltendes Recht falsch. Verlassen wird sich deshalb nicht auf Sorgfalt, |
| 9 | sondern auf eine maschinelle Prüfung – dieser Index ist ihre Grundlage. |
| 10 | |
| 11 | Quelle |
| 12 | ------ |
| 13 | Ausschließlich das amtliche XML von gesetze-im-internet.de, herausgegeben vom |
| 14 | Bundesministerium der Justiz gemeinsam mit der juris GmbH. Die Dateien liegen |
| 15 | unverändert unter `content/gesetze/`; dieses Skript liest sie und schreibt |
| 16 | `content/gesetze/index.json`. |
| 17 | |
| 18 | Gesetzestexte sind nach § 5 Abs. 1 UrhG gemeinfrei. Sie werden nicht mit der |
| 19 | Anwendung ausgeliefert – der Index dient allein der Prüfung im |
| 20 | Entwicklungsbaum. |
| 21 | |
| 22 | Gliederung |
| 23 | ---------- |
| 24 | Das Gesetzes-XML gliedert Aufzählungen als `<DL><DT>1.</DT><DD>…</DD></DL>`, |
| 25 | beliebig tief verschachtelt („1." enthält „a)" enthält „aa)"). Der Index bildet |
| 26 | diese Struktur ab, damit sich eine Fundstelle bis auf die Buchstabenebene |
| 27 | prüfen lässt. |
| 28 | |
| 29 | Anlagen (etwa die Begriffsbestimmungen in Anlage 1 WaffG) folgen einer eigenen |
| 30 | Systematik mit Abschnitten und Unterabschnitten. Für sie wird nur der Volltext |
| 31 | abgelegt; die Prüfung arbeitet dort mit Textsuche statt mit Struktur. |
| 32 | |
| 33 | Aufruf |
| 34 | ------ |
| 35 | python data-pipeline/gesetze_index.py |
| 36 | """ |
| 37 | |
| 38 | from __future__ import annotations |
| 39 | |
| 40 | import io |
| 41 | import json |
| 42 | import re |
| 43 | import xml.etree.ElementTree as ET |
| 44 | from dataclasses import dataclass, field |
| 45 | from datetime import datetime |
| 46 | from pathlib import Path |
| 47 | |
| 48 | WURZEL = Path(__file__).resolve().parent.parent |
| 49 | GESETZE = WURZEL / 'content' / 'gesetze' |
| 50 | ZIEL = GESETZE / 'index.json' |
| 51 | |
| 52 | # Kürzel -> (Dateiname, Bezeichnung, Kennung bei gesetze-im-internet.de) |
| 53 | QUELLEN: dict[str, tuple[str, str, str]] = { |
| 54 | 'WaffG': ('BJNR397010002.xml', 'Waffengesetz', 'waffg_2002'), |
| 55 | 'AWaffV': ('BJNR212300003.xml', 'Allgemeine Waffengesetz-Verordnung', 'awaffv'), |
| 56 | 'BeschG': ('BJNR400300002.xml', 'Beschussgesetz', 'beschg'), |
| 57 | 'BeschussV': ('BJNR147400006.xml', 'Beschussverordnung', 'beschussv'), |
| 58 | 'StGB': ('BJNR001270871.xml', 'Strafgesetzbuch', 'stgb'), |
| 59 | 'SprengG': ('BJNR027370976.xml', 'Sprengstoffgesetz', 'sprengg_1976'), |
| 60 | '1. SprengV': ('BJNR021410977.xml', 'Erste Verordnung zum Sprengstoffgesetz', 'sprengv_1'), |
| 61 | } |
| 62 | |
| 63 | #: „(1)" oder „(1a)" am Absatzanfang. |
| 64 | ABSATZ_MUSTER = re.compile(r'^\(\s*(\d+[a-z]?)\s*\)\s*', re.UNICODE) |
| 65 | |
| 66 | #: Eine Nummernmarke: „1", „12", „3a". Ein blosser Buchstabe ist keine. |
| 67 | NUMMER_MARKE = re.compile(r'^\d+[a-z]?$', re.UNICODE) |
| 68 | |
| 69 | |
| 70 | @dataclass |
| 71 | class Absatz: |
| 72 | nummer: str |
| 73 | text: str |
| 74 | #: Nummer -> darin enthaltene Buchstaben. Leer, wenn nicht gegliedert. |
| 75 | gliederung: dict[str, list[str]] = field(default_factory=dict) |
| 76 | |
| 77 | |
| 78 | @dataclass |
| 79 | class Norm: |
| 80 | """Ein Paragraf oder eine Anlage.""" |
| 81 | |
| 82 | bezeichnung: str # „§ 12" oder „Anlage 1" |
| 83 | titel: str |
| 84 | text: str |
| 85 | absaetze: dict[str, Absatz] = field(default_factory=dict) |
| 86 | ist_anlage: bool = False |
| 87 | |
| 88 | |
| 89 | def text_von(element: ET.Element | None) -> str: |
| 90 | """Fließtext eines Elements mit lesbaren Zeilenumbrüchen.""" |
| 91 | if element is None: |
| 92 | return '' |
| 93 | |
| 94 | teile: list[str] = [] |
| 95 | |
| 96 | def sammeln(knoten: ET.Element) -> None: |
| 97 | # <DT> trägt die Gliederungsmarke („1.", „a)") und beginnt eine Zeile. |
| 98 | if knoten.tag in ('BR', 'DT'): |
| 99 | teile.append('\n') |
| 100 | if knoten.text: |
| 101 | teile.append(knoten.text) |
| 102 | for kind in knoten: |
| 103 | sammeln(kind) |
| 104 | if kind.tail: |
| 105 | teile.append(kind.tail) |
| 106 | |
| 107 | sammeln(element) |
| 108 | roh = ''.join(teile).replace('', '').replace(' ', ' ') |
| 109 | zeilen = [z.strip() for z in roh.split('\n')] |
| 110 | return '\n'.join(z for z in zeilen if z) |
| 111 | |
| 112 | |
| 113 | def direkte_listen(element: ET.Element) -> list[ET.Element]: |
| 114 | """Die obersten `<DL>` unterhalb von `element`. |
| 115 | |
| 116 | Sobald eine Liste gefunden ist, wird nicht weiter hineingestiegen: Ihre |
| 117 | Untergliederung gehört zu ihr, nicht zur Ebene darüber. |
| 118 | """ |
| 119 | gefunden: list[ET.Element] = [] |
| 120 | |
| 121 | def gehe(knoten: ET.Element) -> None: |
| 122 | for kind in knoten: |
| 123 | if kind.tag == 'DL': |
| 124 | gefunden.append(kind) |
| 125 | else: |
| 126 | gehe(kind) |
| 127 | |
| 128 | gehe(element) |
| 129 | return gefunden |
| 130 | |
| 131 | |
| 132 | # Eine brauchbare Gliederungsmarke: Ziffer mit optionalem Kleinbuchstaben |
| 133 | # (»1«, »3a«) oder ein bis zwei Kleinbuchstaben (»a«, »aa«). |
| 134 | MARKE_MUSTER = re.compile(r'^(?:\d{1,3}[a-z]?|[a-z]{1,2})$') |
| 135 | |
| 136 | # Was beim Lesen des amtlichen XML nicht aufging. Wird von `main()` gemeldet: |
| 137 | # Ein Absatz mit unlesbarer Gliederung fällt sonst niemandem auf, und an ihm |
| 138 | # scheitern anschliessend richtige Zitate. |
| 139 | MARKENBEFUNDE: list[str] = [] |
| 140 | |
| 141 | |
| 142 | def liste_lesen(dl: ET.Element, ort: str = '') -> list[tuple[str, list[str]]]: |
| 143 | """Eine `<DL>` als [(Marke, Marken der Untergliederung)]. |
| 144 | |
| 145 | ## Warum Marken geprüft werden |
| 146 | |
| 147 | Das amtliche XML ist nicht überall sauber. Gemessen an der ausgelieferten |
| 148 | Fassung: `BeschG § 11 Abs. 3` trägt ein leeres `<DT />`, weil die »1.« |
| 149 | versehentlich **vor** der Liste im Absatztext steht (»wenn % 1.«); ebenso |
| 150 | `StGB § 46 Abs. 2`. `BeschussV § 39 Abs. 2` liefert »-«, `BeschG § 2 |
| 151 | Abs. 7` ein »*«. |
| 152 | |
| 153 | Bis Fassung 0.24.1 wanderten diese Marken ungeprüft in den Index. Zwei |
| 154 | Folgen: `gesetz.py --gliederung` stürzte an der leeren Marke ab, und die |
| 155 | Fundstellenprüfung wies **richtige** Zitate ab – »§ 11 Abs. 3 Nr. 1 |
| 156 | BeschG« gibt es, der Index kannte die Nummer aber als Leerzeichenkette. |
| 157 | |
| 158 | Eine leere Marke wird deshalb aus ihrer **Stelle** in der Liste |
| 159 | hergeleitet, aber nur, wenn das eindeutig ist: Die Liste muss aufsteigend |
| 160 | numerisch sein und die hergeleitete Nummer darf noch nicht vorkommen. |
| 161 | Jeder Fall wird gemeldet – geraten wird hier nichts stumm. |
| 162 | """ |
| 163 | eintraege: list[tuple[str, list[str]]] = [] |
| 164 | marke: str | None = None |
| 165 | stelle = 0 |
| 166 | |
| 167 | for kind in dl: |
| 168 | if kind.tag == 'DT': |
| 169 | marke = ''.join(kind.itertext()).strip().rstrip('.)') |
| 170 | elif kind.tag == 'DD' and marke is not None: |
| 171 | stelle += 1 |
| 172 | unter: list[str] = [] |
| 173 | for tiefer in direkte_listen(kind): |
| 174 | unter.extend(m for m, _ in liste_lesen(tiefer, ort)) |
| 175 | |
| 176 | # Anlagen bleiben aussen vor: Fuer sie legt der Index nur den |
| 177 | # Volltext ab (siehe Modulkopf), ihre Gliederung wird verworfen. |
| 178 | # Sie zaehlen dutzendweise Punkte wie »3.4.1« und Kuerzel wie |
| 179 | # »SW« auf – als Befund waere das nur Rauschen, das die neun |
| 180 | # echten Faelle in den Paragrafen zudeckt. |
| 181 | if not MARKE_MUSTER.match(marke) and not ort.lower().startswith('anlage'): |
| 182 | ersatz = str(stelle) |
| 183 | schon = {m for m, _ in eintraege} |
| 184 | if not marke and ersatz not in schon: |
| 185 | MARKENBEFUNDE.append( |
| 186 | f'{ort}: leere Gliederungsmarke an Stelle {stelle} – ' |
| 187 | f'als »{ersatz}« gefuehrt (amtliches XML unvollstaendig)' |
| 188 | ) |
| 189 | marke = ersatz |
| 190 | else: |
| 191 | MARKENBEFUNDE.append( |
| 192 | f'{ort}: unbrauchbare Gliederungsmarke {marke!r} an Stelle ' |
| 193 | f'{stelle} – nicht in den Index aufgenommen' |
| 194 | ) |
| 195 | marke = None |
| 196 | continue |
| 197 | |
| 198 | eintraege.append((marke, unter)) |
| 199 | marke = None |
| 200 | |
| 201 | return eintraege |
| 202 | |
| 203 | |
| 204 | def falten(gliederung: dict[str, list[str]]) -> dict[str, list[str]]: |
| 205 | """Schlägt Buchstaben auf Nummernebene der vorangehenden Nummer zu. |
| 206 | |
| 207 | Das Gesetzes-XML setzt Untergliederungen nicht immer in das `<DD>` ihrer |
| 208 | Nummer; in § 12 Abs. 4 WaffG etwa stehen die Buchstaben zu Nr. 3 als |
| 209 | eigene Liste daneben. Ohne diese Korrektur entstünde daraus eine |
| 210 | „Nr. a", die es nicht gibt – und eine Fundstelle darauf käme durch die |
| 211 | Prüfung. |
| 212 | |
| 213 | Steht dagegen gar keine Nummer voran, ist die Buchstabengliederung echt: |
| 214 | § 3a Abs. 2 SprengG zählt unmittelbar mit „a)" und „b)" auf. |
| 215 | """ |
| 216 | ergebnis: dict[str, list[str]] = {} |
| 217 | letzte_nummer: str | None = None |
| 218 | |
| 219 | for marke, unter in gliederung.items(): |
| 220 | if NUMMER_MARKE.match(marke): |
| 221 | ergebnis[marke] = list(unter) |
| 222 | letzte_nummer = marke |
| 223 | elif letzte_nummer is not None: |
| 224 | ergebnis[letzte_nummer].append(marke) |
| 225 | ergebnis[letzte_nummer].extend(unter) |
| 226 | else: |
| 227 | ergebnis[marke] = list(unter) |
| 228 | |
| 229 | return ergebnis |
| 230 | |
| 231 | |
| 232 | def gliederung_von(element: ET.Element, ort: str = '') -> dict[str, list[str]]: |
| 233 | """Gliederung eines Absatz-Blocks als Marke -> Untermarken.""" |
| 234 | gliederung: dict[str, list[str]] = {} |
| 235 | for dl in direkte_listen(element): |
| 236 | for marke, unter in liste_lesen(dl, ort): |
| 237 | # Bei mehreren Listen im selben Absatz gewinnt die erste Nennung; |
| 238 | # doppelte Marken kommen im Gesetzestext nicht vor. |
| 239 | gliederung.setdefault(marke, unter) |
| 240 | return falten(gliederung) |
| 241 | |
| 242 | |
| 243 | def absaetze_lesen(bloecke: list[tuple[str, dict[str, list[str]]]]) -> dict[str, Absatz]: |
| 244 | """Ordnet die `<P>`-Blöcke ihren Absatznummern zu. |
| 245 | |
| 246 | Ein Absatz kann sich über mehrere `<P>` erstrecken. Ein Block ohne eigene |
| 247 | Nummer gehört deshalb zum zuletzt begonnenen Absatz. |
| 248 | """ |
| 249 | absaetze: dict[str, Absatz] = {} |
| 250 | laufend: str | None = None |
| 251 | |
| 252 | for text, gliederung in bloecke: |
| 253 | treffer = ABSATZ_MUSTER.match(text) |
| 254 | if treffer: |
| 255 | laufend = treffer.group(1) |
| 256 | absaetze[laufend] = Absatz( |
| 257 | nummer=laufend, |
| 258 | text=text[treffer.end() :], |
| 259 | gliederung=dict(gliederung), |
| 260 | ) |
| 261 | elif laufend is not None: |
| 262 | absaetze[laufend].text += '\n' + text |
| 263 | for marke, unter in gliederung.items(): |
| 264 | absaetze[laufend].gliederung.setdefault(marke, unter) |
| 265 | |
| 266 | return absaetze |
| 267 | |
| 268 | |
| 269 | def norm_lesen(norm: ET.Element) -> Norm | None: |
| 270 | md = norm.find('metadaten') |
| 271 | if md is None: |
| 272 | return None |
| 273 | |
| 274 | bezeichnung = (md.findtext('enbez') or '').strip() |
| 275 | if not bezeichnung: |
| 276 | return None |
| 277 | |
| 278 | inhalt = norm.find('textdaten/text/Content') |
| 279 | bloecke: list[tuple[str, dict[str, list[str]]]] = [] |
| 280 | if inhalt is not None: |
| 281 | for block in inhalt: |
| 282 | text = text_von(block) |
| 283 | if text: |
| 284 | bloecke.append((text, gliederung_von(block, bezeichnung))) |
| 285 | |
| 286 | ist_anlage = bezeichnung.lower().startswith('anlage') |
| 287 | |
| 288 | return Norm( |
| 289 | bezeichnung=bezeichnung, |
| 290 | titel=(md.findtext('titel') or '').strip(), |
| 291 | text='\n'.join(t for t, _ in bloecke), |
| 292 | absaetze={} if ist_anlage else absaetze_lesen(bloecke), |
| 293 | ist_anlage=ist_anlage, |
| 294 | ) |
| 295 | |
| 296 | |
| 297 | def gesetz_lesen(pfad: Path) -> tuple[dict[str, str], dict[str, Norm]]: |
| 298 | wurzel = ET.parse(pfad).getroot() |
| 299 | kopf: dict[str, str] = {} |
| 300 | normen: dict[str, Norm] = {} |
| 301 | |
| 302 | for norm_element in wurzel.findall('norm'): |
| 303 | md = norm_element.find('metadaten') |
| 304 | if md is None: |
| 305 | continue |
| 306 | |
| 307 | # Der erste Datensatz trägt die Angaben zum Gesetz als Ganzes. |
| 308 | if not kopf: |
| 309 | stand = md.find('standangabe/standkommentar') |
| 310 | kopf = { |
| 311 | 'jurabk': (md.findtext('jurabk') or '').strip(), |
| 312 | 'amtabk': (md.findtext('amtabk') or '').strip(), |
| 313 | 'langtitel': (md.findtext('langue') or '').strip(), |
| 314 | 'ausfertigung': (md.findtext('ausfertigung-datum') or '').strip(), |
| 315 | 'stand': (stand.text or '').strip() if stand is not None else '', |
| 316 | } |
| 317 | |
| 318 | norm = norm_lesen(norm_element) |
| 319 | if norm is not None and (norm.bezeichnung.startswith('§') or norm.ist_anlage): |
| 320 | normen[norm.bezeichnung] = norm |
| 321 | |
| 322 | return kopf, normen |
| 323 | |
| 324 | |
| 325 | def abzugsdatum(pfad: Path) -> str: |
| 326 | """Wann dieser Abzug gezogen wurde – das Datum der XML-Datei. |
| 327 | |
| 328 | **Warum die Dateizeit und nicht die Laufzeit.** Der Erzeuger liest die |
| 329 | XML-Dateien, er holt sie nicht. Ein Datum aus dem Lauf sagte also nur, |
| 330 | wann der Index neu gebaut wurde, und nicht, wie alt der Gesetzestext |
| 331 | darin ist. Genau das war die Lücke: `content/gesetze/index.json` führte |
| 332 | die Standangabe des Gesetzes („Zuletzt geändert durch …"), aber nirgends |
| 333 | stand, wann jemand zuletzt nachgesehen hat, ob es dabei geblieben ist. |
| 334 | Am 01.09.2026 lagen zwischen dem ältesten und dem jüngsten Abzug |
| 335 | **sechs Jahre**. |
| 336 | |
| 337 | Bewusst kein Test, der mit dem Kalender rot wird: Eine Prüfung, die |
| 338 | allein durch Zeitablauf ausfällt, ist am Tag ihres Ausfalls kein Befund, |
| 339 | sondern eine Störung – dieselbe Bauart, die in `selbstsicherung.test.ts` |
| 340 | schon einmal zugeschlagen hat. Sichtbar gemacht wird das Alter hier und |
| 341 | in der Liste unter `docs/veroeffentlichen.md`; entscheiden muss es ein |
| 342 | Mensch, denn nur er kann nachsehen, ob sich die Vorschrift geändert hat. |
| 343 | """ |
| 344 | return datetime.fromtimestamp(pfad.stat().st_mtime).date().isoformat() |
| 345 | |
| 346 | def main() -> int: |
| 347 | gesetze: dict[str, object] = {} |
| 348 | |
| 349 | for kuerzel, (datei, bezeichnung, kennung) in QUELLEN.items(): |
| 350 | pfad = GESETZE / datei |
| 351 | if not pfad.exists(): |
| 352 | print(f'FEHLT: {kuerzel} ({datei})') |
| 353 | return 1 |
| 354 | |
| 355 | kopf, normen = gesetz_lesen(pfad) |
| 356 | paragrafen = {b: n for b, n in normen.items() if not n.ist_anlage} |
| 357 | anlagen = {b: n for b, n in normen.items() if n.ist_anlage} |
| 358 | |
| 359 | gesetze[kuerzel] = { |
| 360 | 'bezeichnung': bezeichnung, |
| 361 | 'langtitel': kopf.get('langtitel', ''), |
| 362 | 'stand': kopf.get('stand', ''), |
| 363 | 'ausfertigung': kopf.get('ausfertigung', ''), |
| 364 | 'quelle': f'https://www.gesetze-im-internet.de/{kennung}/', |
| 365 | 'abzug': abzugsdatum(pfad), |
| 366 | 'normen': { |
| 367 | b: { |
| 368 | 'titel': n.titel, |
| 369 | 'ist_anlage': n.ist_anlage, |
| 370 | 'absaetze': { |
| 371 | a.nummer: {'gliederung': a.gliederung, 'text': a.text} |
| 372 | for a in n.absaetze.values() |
| 373 | }, |
| 374 | 'text': n.text, |
| 375 | } |
| 376 | for b, n in normen.items() |
| 377 | }, |
| 378 | } |
| 379 | |
| 380 | gegliedert = sum( |
| 381 | 1 for n in paragrafen.values() for a in n.absaetze.values() if a.gliederung |
| 382 | ) |
| 383 | print( |
| 384 | f'{kuerzel:10} {len(paragrafen):4} Paragrafen, {len(anlagen)} Anlagen, ' |
| 385 | f'{gegliedert:4} gegliederte Absaetze' |
| 386 | ) |
| 387 | |
| 388 | ergebnis = { |
| 389 | 'hinweis': ( |
| 390 | 'Maschinell erzeugt aus dem amtlichen XML von gesetze-im-internet.de ' |
| 391 | '(Bundesministerium der Justiz / juris GmbH). Nicht von Hand aendern - ' |
| 392 | 'neu erzeugen mit: python data-pipeline/gesetze_index.py' |
| 393 | ), |
| 394 | 'gesetze': gesetze, |
| 395 | } |
| 396 | |
| 397 | with io.open(ZIEL, 'w', encoding='utf-8', newline='\n') as datei_aus: |
| 398 | json.dump(ergebnis, datei_aus, ensure_ascii=False, indent=1) |
| 399 | |
| 400 | print(f'\ngeschrieben: {ZIEL.name} ({ZIEL.stat().st_size / 1_048_576:.1f} MB)') |
| 401 | |
| 402 | # Was beim Lesen nicht aufging, gehört ausgesprochen. Ein Absatz mit |
| 403 | # unlesbarer Gliederung fällt sonst niemandem auf – und an ihm scheitern |
| 404 | # anschliessend richtige Zitate. |
| 405 | if MARKENBEFUNDE: |
| 406 | print(f'\nUnsaubere Gliederungsmarken im amtlichen XML ({len(MARKENBEFUNDE)}):') |
| 407 | for befund in MARKENBEFUNDE: |
| 408 | print(f' {befund}') |
| 409 | |
| 410 | return 0 |
| 411 | |
| 412 | |
| 413 | if __name__ == '__main__': |
| 414 | raise SystemExit(main()) |