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 pathlib import Path |
| 46 | |
| 47 | WURZEL = Path(__file__).resolve().parent.parent |
| 48 | GESETZE = WURZEL / 'content' / 'gesetze' |
| 49 | ZIEL = GESETZE / 'index.json' |
| 50 | |
| 51 | # Kürzel -> (Dateiname, Bezeichnung, Kennung bei gesetze-im-internet.de) |
| 52 | QUELLEN: dict[str, tuple[str, str, str]] = { |
| 53 | 'WaffG': ('BJNR397010002.xml', 'Waffengesetz', 'waffg_2002'), |
| 54 | 'AWaffV': ('BJNR212300003.xml', 'Allgemeine Waffengesetz-Verordnung', 'awaffv'), |
| 55 | 'BeschG': ('BJNR400300002.xml', 'Beschussgesetz', 'beschg'), |
| 56 | 'BeschussV': ('BJNR147400006.xml', 'Beschussverordnung', 'beschussv'), |
| 57 | 'StGB': ('BJNR001270871.xml', 'Strafgesetzbuch', 'stgb'), |
| 58 | 'SprengG': ('BJNR027370976.xml', 'Sprengstoffgesetz', 'sprengg_1976'), |
| 59 | '1. SprengV': ('BJNR021410977.xml', 'Erste Verordnung zum Sprengstoffgesetz', 'sprengv_1'), |
| 60 | } |
| 61 | |
| 62 | #: „(1)" oder „(1a)" am Absatzanfang. |
| 63 | ABSATZ_MUSTER = re.compile(r'^\(\s*(\d+[a-z]?)\s*\)\s*', re.UNICODE) |
| 64 | |
| 65 | #: Eine Nummernmarke: „1", „12", „3a". Ein blosser Buchstabe ist keine. |
| 66 | NUMMER_MARKE = re.compile(r'^\d+[a-z]?$', re.UNICODE) |
| 67 | |
| 68 | |
| 69 | @dataclass |
| 70 | class Absatz: |
| 71 | nummer: str |
| 72 | text: str |
| 73 | #: Nummer -> darin enthaltene Buchstaben. Leer, wenn nicht gegliedert. |
| 74 | gliederung: dict[str, list[str]] = field(default_factory=dict) |
| 75 | |
| 76 | |
| 77 | @dataclass |
| 78 | class Norm: |
| 79 | """Ein Paragraf oder eine Anlage.""" |
| 80 | |
| 81 | bezeichnung: str # „§ 12" oder „Anlage 1" |
| 82 | titel: str |
| 83 | text: str |
| 84 | absaetze: dict[str, Absatz] = field(default_factory=dict) |
| 85 | ist_anlage: bool = False |
| 86 | |
| 87 | |
| 88 | def text_von(element: ET.Element | None) -> str: |
| 89 | """Fließtext eines Elements mit lesbaren Zeilenumbrüchen.""" |
| 90 | if element is None: |
| 91 | return '' |
| 92 | |
| 93 | teile: list[str] = [] |
| 94 | |
| 95 | def sammeln(knoten: ET.Element) -> None: |
| 96 | # <DT> trägt die Gliederungsmarke („1.", „a)") und beginnt eine Zeile. |
| 97 | if knoten.tag in ('BR', 'DT'): |
| 98 | teile.append('\n') |
| 99 | if knoten.text: |
| 100 | teile.append(knoten.text) |
| 101 | for kind in knoten: |
| 102 | sammeln(kind) |
| 103 | if kind.tail: |
| 104 | teile.append(kind.tail) |
| 105 | |
| 106 | sammeln(element) |
| 107 | roh = ''.join(teile).replace('', '').replace(' ', ' ') |
| 108 | zeilen = [z.strip() for z in roh.split('\n')] |
| 109 | return '\n'.join(z for z in zeilen if z) |
| 110 | |
| 111 | |
| 112 | def direkte_listen(element: ET.Element) -> list[ET.Element]: |
| 113 | """Die obersten `<DL>` unterhalb von `element`. |
| 114 | |
| 115 | Sobald eine Liste gefunden ist, wird nicht weiter hineingestiegen: Ihre |
| 116 | Untergliederung gehört zu ihr, nicht zur Ebene darüber. |
| 117 | """ |
| 118 | gefunden: list[ET.Element] = [] |
| 119 | |
| 120 | def gehe(knoten: ET.Element) -> None: |
| 121 | for kind in knoten: |
| 122 | if kind.tag == 'DL': |
| 123 | gefunden.append(kind) |
| 124 | else: |
| 125 | gehe(kind) |
| 126 | |
| 127 | gehe(element) |
| 128 | return gefunden |
| 129 | |
| 130 | |
| 131 | # Eine brauchbare Gliederungsmarke: Ziffer mit optionalem Kleinbuchstaben |
| 132 | # (»1«, »3a«) oder ein bis zwei Kleinbuchstaben (»a«, »aa«). |
| 133 | MARKE_MUSTER = re.compile(r'^(?:\d{1,3}[a-z]?|[a-z]{1,2})$') |
| 134 | |
| 135 | # Was beim Lesen des amtlichen XML nicht aufging. Wird von `main()` gemeldet: |
| 136 | # Ein Absatz mit unlesbarer Gliederung fällt sonst niemandem auf, und an ihm |
| 137 | # scheitern anschliessend richtige Zitate. |
| 138 | MARKENBEFUNDE: list[str] = [] |
| 139 | |
| 140 | |
| 141 | def liste_lesen(dl: ET.Element, ort: str = '') -> list[tuple[str, list[str]]]: |
| 142 | """Eine `<DL>` als [(Marke, Marken der Untergliederung)]. |
| 143 | |
| 144 | ## Warum Marken geprüft werden |
| 145 | |
| 146 | Das amtliche XML ist nicht überall sauber. Gemessen an der ausgelieferten |
| 147 | Fassung: `BeschG § 11 Abs. 3` trägt ein leeres `<DT />`, weil die »1.« |
| 148 | versehentlich **vor** der Liste im Absatztext steht (»wenn % 1.«); ebenso |
| 149 | `StGB § 46 Abs. 2`. `BeschussV § 39 Abs. 2` liefert »-«, `BeschG § 2 |
| 150 | Abs. 7` ein »*«. |
| 151 | |
| 152 | Bis Fassung 0.24.1 wanderten diese Marken ungeprüft in den Index. Zwei |
| 153 | Folgen: `gesetz.py --gliederung` stürzte an der leeren Marke ab, und die |
| 154 | Fundstellenprüfung wies **richtige** Zitate ab – »§ 11 Abs. 3 Nr. 1 |
| 155 | BeschG« gibt es, der Index kannte die Nummer aber als Leerzeichenkette. |
| 156 | |
| 157 | Eine leere Marke wird deshalb aus ihrer **Stelle** in der Liste |
| 158 | hergeleitet, aber nur, wenn das eindeutig ist: Die Liste muss aufsteigend |
| 159 | numerisch sein und die hergeleitete Nummer darf noch nicht vorkommen. |
| 160 | Jeder Fall wird gemeldet – geraten wird hier nichts stumm. |
| 161 | """ |
| 162 | eintraege: list[tuple[str, list[str]]] = [] |
| 163 | marke: str | None = None |
| 164 | stelle = 0 |
| 165 | |
| 166 | for kind in dl: |
| 167 | if kind.tag == 'DT': |
| 168 | marke = ''.join(kind.itertext()).strip().rstrip('.)') |
| 169 | elif kind.tag == 'DD' and marke is not None: |
| 170 | stelle += 1 |
| 171 | unter: list[str] = [] |
| 172 | for tiefer in direkte_listen(kind): |
| 173 | unter.extend(m for m, _ in liste_lesen(tiefer, ort)) |
| 174 | |
| 175 | # Anlagen bleiben aussen vor: Fuer sie legt der Index nur den |
| 176 | # Volltext ab (siehe Modulkopf), ihre Gliederung wird verworfen. |
| 177 | # Sie zaehlen dutzendweise Punkte wie »3.4.1« und Kuerzel wie |
| 178 | # »SW« auf – als Befund waere das nur Rauschen, das die neun |
| 179 | # echten Faelle in den Paragrafen zudeckt. |
| 180 | if not MARKE_MUSTER.match(marke) and not ort.lower().startswith('anlage'): |
| 181 | ersatz = str(stelle) |
| 182 | schon = {m for m, _ in eintraege} |
| 183 | if not marke and ersatz not in schon: |
| 184 | MARKENBEFUNDE.append( |
| 185 | f'{ort}: leere Gliederungsmarke an Stelle {stelle} – ' |
| 186 | f'als »{ersatz}« gefuehrt (amtliches XML unvollstaendig)' |
| 187 | ) |
| 188 | marke = ersatz |
| 189 | else: |
| 190 | MARKENBEFUNDE.append( |
| 191 | f'{ort}: unbrauchbare Gliederungsmarke {marke!r} an Stelle ' |
| 192 | f'{stelle} – nicht in den Index aufgenommen' |
| 193 | ) |
| 194 | marke = None |
| 195 | continue |
| 196 | |
| 197 | eintraege.append((marke, unter)) |
| 198 | marke = None |
| 199 | |
| 200 | return eintraege |
| 201 | |
| 202 | |
| 203 | def falten(gliederung: dict[str, list[str]]) -> dict[str, list[str]]: |
| 204 | """Schlägt Buchstaben auf Nummernebene der vorangehenden Nummer zu. |
| 205 | |
| 206 | Das Gesetzes-XML setzt Untergliederungen nicht immer in das `<DD>` ihrer |
| 207 | Nummer; in § 12 Abs. 4 WaffG etwa stehen die Buchstaben zu Nr. 3 als |
| 208 | eigene Liste daneben. Ohne diese Korrektur entstünde daraus eine |
| 209 | „Nr. a", die es nicht gibt – und eine Fundstelle darauf käme durch die |
| 210 | Prüfung. |
| 211 | |
| 212 | Steht dagegen gar keine Nummer voran, ist die Buchstabengliederung echt: |
| 213 | § 3a Abs. 2 SprengG zählt unmittelbar mit „a)" und „b)" auf. |
| 214 | """ |
| 215 | ergebnis: dict[str, list[str]] = {} |
| 216 | letzte_nummer: str | None = None |
| 217 | |
| 218 | for marke, unter in gliederung.items(): |
| 219 | if NUMMER_MARKE.match(marke): |
| 220 | ergebnis[marke] = list(unter) |
| 221 | letzte_nummer = marke |
| 222 | elif letzte_nummer is not None: |
| 223 | ergebnis[letzte_nummer].append(marke) |
| 224 | ergebnis[letzte_nummer].extend(unter) |
| 225 | else: |
| 226 | ergebnis[marke] = list(unter) |
| 227 | |
| 228 | return ergebnis |
| 229 | |
| 230 | |
| 231 | def gliederung_von(element: ET.Element, ort: str = '') -> dict[str, list[str]]: |
| 232 | """Gliederung eines Absatz-Blocks als Marke -> Untermarken.""" |
| 233 | gliederung: dict[str, list[str]] = {} |
| 234 | for dl in direkte_listen(element): |
| 235 | for marke, unter in liste_lesen(dl, ort): |
| 236 | # Bei mehreren Listen im selben Absatz gewinnt die erste Nennung; |
| 237 | # doppelte Marken kommen im Gesetzestext nicht vor. |
| 238 | gliederung.setdefault(marke, unter) |
| 239 | return falten(gliederung) |
| 240 | |
| 241 | |
| 242 | def absaetze_lesen(bloecke: list[tuple[str, dict[str, list[str]]]]) -> dict[str, Absatz]: |
| 243 | """Ordnet die `<P>`-Blöcke ihren Absatznummern zu. |
| 244 | |
| 245 | Ein Absatz kann sich über mehrere `<P>` erstrecken. Ein Block ohne eigene |
| 246 | Nummer gehört deshalb zum zuletzt begonnenen Absatz. |
| 247 | """ |
| 248 | absaetze: dict[str, Absatz] = {} |
| 249 | laufend: str | None = None |
| 250 | |
| 251 | for text, gliederung in bloecke: |
| 252 | treffer = ABSATZ_MUSTER.match(text) |
| 253 | if treffer: |
| 254 | laufend = treffer.group(1) |
| 255 | absaetze[laufend] = Absatz( |
| 256 | nummer=laufend, |
| 257 | text=text[treffer.end() :], |
| 258 | gliederung=dict(gliederung), |
| 259 | ) |
| 260 | elif laufend is not None: |
| 261 | absaetze[laufend].text += '\n' + text |
| 262 | for marke, unter in gliederung.items(): |
| 263 | absaetze[laufend].gliederung.setdefault(marke, unter) |
| 264 | |
| 265 | return absaetze |
| 266 | |
| 267 | |
| 268 | def norm_lesen(norm: ET.Element) -> Norm | None: |
| 269 | md = norm.find('metadaten') |
| 270 | if md is None: |
| 271 | return None |
| 272 | |
| 273 | bezeichnung = (md.findtext('enbez') or '').strip() |
| 274 | if not bezeichnung: |
| 275 | return None |
| 276 | |
| 277 | inhalt = norm.find('textdaten/text/Content') |
| 278 | bloecke: list[tuple[str, dict[str, list[str]]]] = [] |
| 279 | if inhalt is not None: |
| 280 | for block in inhalt: |
| 281 | text = text_von(block) |
| 282 | if text: |
| 283 | bloecke.append((text, gliederung_von(block, bezeichnung))) |
| 284 | |
| 285 | ist_anlage = bezeichnung.lower().startswith('anlage') |
| 286 | |
| 287 | return Norm( |
| 288 | bezeichnung=bezeichnung, |
| 289 | titel=(md.findtext('titel') or '').strip(), |
| 290 | text='\n'.join(t for t, _ in bloecke), |
| 291 | absaetze={} if ist_anlage else absaetze_lesen(bloecke), |
| 292 | ist_anlage=ist_anlage, |
| 293 | ) |
| 294 | |
| 295 | |
| 296 | def gesetz_lesen(pfad: Path) -> tuple[dict[str, str], dict[str, Norm]]: |
| 297 | wurzel = ET.parse(pfad).getroot() |
| 298 | kopf: dict[str, str] = {} |
| 299 | normen: dict[str, Norm] = {} |
| 300 | |
| 301 | for norm_element in wurzel.findall('norm'): |
| 302 | md = norm_element.find('metadaten') |
| 303 | if md is None: |
| 304 | continue |
| 305 | |
| 306 | # Der erste Datensatz trägt die Angaben zum Gesetz als Ganzes. |
| 307 | if not kopf: |
| 308 | stand = md.find('standangabe/standkommentar') |
| 309 | kopf = { |
| 310 | 'jurabk': (md.findtext('jurabk') or '').strip(), |
| 311 | 'amtabk': (md.findtext('amtabk') or '').strip(), |
| 312 | 'langtitel': (md.findtext('langue') or '').strip(), |
| 313 | 'ausfertigung': (md.findtext('ausfertigung-datum') or '').strip(), |
| 314 | 'stand': (stand.text or '').strip() if stand is not None else '', |
| 315 | } |
| 316 | |
| 317 | norm = norm_lesen(norm_element) |
| 318 | if norm is not None and (norm.bezeichnung.startswith('§') or norm.ist_anlage): |
| 319 | normen[norm.bezeichnung] = norm |
| 320 | |
| 321 | return kopf, normen |
| 322 | |
| 323 | |
| 324 | def main() -> int: |
| 325 | gesetze: dict[str, object] = {} |
| 326 | |
| 327 | for kuerzel, (datei, bezeichnung, kennung) in QUELLEN.items(): |
| 328 | pfad = GESETZE / datei |
| 329 | if not pfad.exists(): |
| 330 | print(f'FEHLT: {kuerzel} ({datei})') |
| 331 | return 1 |
| 332 | |
| 333 | kopf, normen = gesetz_lesen(pfad) |
| 334 | paragrafen = {b: n for b, n in normen.items() if not n.ist_anlage} |
| 335 | anlagen = {b: n for b, n in normen.items() if n.ist_anlage} |
| 336 | |
| 337 | gesetze[kuerzel] = { |
| 338 | 'bezeichnung': bezeichnung, |
| 339 | 'langtitel': kopf.get('langtitel', ''), |
| 340 | 'stand': kopf.get('stand', ''), |
| 341 | 'ausfertigung': kopf.get('ausfertigung', ''), |
| 342 | 'quelle': f'https://www.gesetze-im-internet.de/{kennung}/', |
| 343 | 'normen': { |
| 344 | b: { |
| 345 | 'titel': n.titel, |
| 346 | 'ist_anlage': n.ist_anlage, |
| 347 | 'absaetze': { |
| 348 | a.nummer: {'gliederung': a.gliederung, 'text': a.text} |
| 349 | for a in n.absaetze.values() |
| 350 | }, |
| 351 | 'text': n.text, |
| 352 | } |
| 353 | for b, n in normen.items() |
| 354 | }, |
| 355 | } |
| 356 | |
| 357 | gegliedert = sum( |
| 358 | 1 for n in paragrafen.values() for a in n.absaetze.values() if a.gliederung |
| 359 | ) |
| 360 | print( |
| 361 | f'{kuerzel:10} {len(paragrafen):4} Paragrafen, {len(anlagen)} Anlagen, ' |
| 362 | f'{gegliedert:4} gegliederte Absaetze' |
| 363 | ) |
| 364 | |
| 365 | ergebnis = { |
| 366 | 'hinweis': ( |
| 367 | 'Maschinell erzeugt aus dem amtlichen XML von gesetze-im-internet.de ' |
| 368 | '(Bundesministerium der Justiz / juris GmbH). Nicht von Hand aendern - ' |
| 369 | 'neu erzeugen mit: python data-pipeline/gesetze_index.py' |
| 370 | ), |
| 371 | 'gesetze': gesetze, |
| 372 | } |
| 373 | |
| 374 | with io.open(ZIEL, 'w', encoding='utf-8', newline='\n') as datei_aus: |
| 375 | json.dump(ergebnis, datei_aus, ensure_ascii=False, indent=1) |
| 376 | |
| 377 | print(f'\ngeschrieben: {ZIEL.name} ({ZIEL.stat().st_size / 1_048_576:.1f} MB)') |
| 378 | |
| 379 | # Was beim Lesen nicht aufging, gehört ausgesprochen. Ein Absatz mit |
| 380 | # unlesbarer Gliederung fällt sonst niemandem auf – und an ihm scheitern |
| 381 | # anschliessend richtige Zitate. |
| 382 | if MARKENBEFUNDE: |
| 383 | print(f'\nUnsaubere Gliederungsmarken im amtlichen XML ({len(MARKENBEFUNDE)}):') |
| 384 | for befund in MARKENBEFUNDE: |
| 385 | print(f' {befund}') |
| 386 | |
| 387 | return 0 |
| 388 | |
| 389 | |
| 390 | if __name__ == '__main__': |
| 391 | raise SystemExit(main()) |