waffensachkunde

Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.

/ data-pipeline gesetze_index.py

16,9 KB Rohdatei
data-pipeline/gesetze_index.py — 448 Zeilen
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 standangabe_lesen(md: ET.Element) -> str:
298 """Die Fassungsangabe des Gesetzes – der Änderungsstand, nicht die Neufassung.
299
300 Das amtliche XML führt ``<standangabe>`` je nach Gesetz ein- oder zweimal.
301 Wer seit seiner Neufassung geändert wurde, trägt zuerst einen Eintrag vom
302 Typ ``Neuf`` („Neugefasst durch Bek. v. …“) und danach den vom Typ
303 ``Stand`` („zuletzt geändert durch …“). Gebraucht wird der zweite: Er sagt,
304 welche Fassung hier vorliegt.
305
306 Bis Fassung 0.27.2 stand hier ``md.find('standangabe/standkommentar')`` –
307 der **erste** Eintrag, ohne Blick auf den Typ. Bei StGB, SprengG und
308 1. SprengV, zusammen 40 der 148 ausgelieferten Normen, erschien dadurch
309 unter dem Wortlaut die Bekanntmachung statt des Änderungsstands: beim StGB
310 die vom 13.11.1998 statt der Änderung vom 20.03.2026, 28 Jahre daneben.
311 Schwerer wog die zweite Wirkung: Der Freigabeschritt „Gesetzesstand
312 ansehen“ in ``docs/veroeffentlichen.md`` vergleicht diese Zeichenkette mit
313 gesetze-im-internet.de – und eine Neufassungs-Bekanntmachung ändert sich
314 durch keine Gesetzesänderung. Für diese drei Gesetze konnte der Schritt
315 nie anschlagen.
316
317 Fehlt ein ``Stand``-Eintrag, gilt der erste vorhandene: Ein Gesetz, das
318 seit seiner Neufassung unverändert ist, hat in ihr seine Fassungsangabe.
319 """
320 erste = ''
321 for angabe in md.findall('standangabe'):
322 text = (angabe.findtext('standkommentar') or '').strip()
323 if not text:
324 continue
325 if not erste:
326 erste = text
327 if (angabe.findtext('standtyp') or '').strip() == 'Stand':
328 return text
329 return erste
330
331
332 def gesetz_lesen(pfad: Path) -> tuple[dict[str, str], dict[str, Norm]]:
333 wurzel = ET.parse(pfad).getroot()
334 kopf: dict[str, str] = {}
335 normen: dict[str, Norm] = {}
336
337 for norm_element in wurzel.findall('norm'):
338 md = norm_element.find('metadaten')
339 if md is None:
340 continue
341
342 # Der erste Datensatz trägt die Angaben zum Gesetz als Ganzes.
343 if not kopf:
344 kopf = {
345 'jurabk': (md.findtext('jurabk') or '').strip(),
346 'amtabk': (md.findtext('amtabk') or '').strip(),
347 'langtitel': (md.findtext('langue') or '').strip(),
348 'ausfertigung': (md.findtext('ausfertigung-datum') or '').strip(),
349 'stand': standangabe_lesen(md),
350 }
351
352 norm = norm_lesen(norm_element)
353 if norm is not None and (norm.bezeichnung.startswith('§') or norm.ist_anlage):
354 normen[norm.bezeichnung] = norm
355
356 return kopf, normen
357
358
359 def abzugsdatum(pfad: Path) -> str:
360 """Wann dieser Abzug gezogen wurde – das Datum der XML-Datei.
361
362 **Warum die Dateizeit und nicht die Laufzeit.** Der Erzeuger liest die
363 XML-Dateien, er holt sie nicht. Ein Datum aus dem Lauf sagte also nur,
364 wann der Index neu gebaut wurde, und nicht, wie alt der Gesetzestext
365 darin ist. Genau das war die Lücke: `content/gesetze/index.json` führte
366 die Standangabe des Gesetzes („Zuletzt geändert durch …"), aber nirgends
367 stand, wann jemand zuletzt nachgesehen hat, ob es dabei geblieben ist.
368 Am 01.09.2026 lagen zwischen dem ältesten und dem jüngsten Abzug
369 **sechs Jahre**.
370
371 Bewusst kein Test, der mit dem Kalender rot wird: Eine Prüfung, die
372 allein durch Zeitablauf ausfällt, ist am Tag ihres Ausfalls kein Befund,
373 sondern eine Störung – dieselbe Bauart, die in `selbstsicherung.test.ts`
374 schon einmal zugeschlagen hat. Sichtbar gemacht wird das Alter hier und
375 in der Liste unter `docs/veroeffentlichen.md`; entscheiden muss es ein
376 Mensch, denn nur er kann nachsehen, ob sich die Vorschrift geändert hat.
377 """
378 return datetime.fromtimestamp(pfad.stat().st_mtime).date().isoformat()
379
380 def main() -> int:
381 gesetze: dict[str, object] = {}
382
383 for kuerzel, (datei, bezeichnung, kennung) in QUELLEN.items():
384 pfad = GESETZE / datei
385 if not pfad.exists():
386 print(f'FEHLT: {kuerzel} ({datei})')
387 return 1
388
389 kopf, normen = gesetz_lesen(pfad)
390 paragrafen = {b: n for b, n in normen.items() if not n.ist_anlage}
391 anlagen = {b: n for b, n in normen.items() if n.ist_anlage}
392
393 gesetze[kuerzel] = {
394 'bezeichnung': bezeichnung,
395 'langtitel': kopf.get('langtitel', ''),
396 'stand': kopf.get('stand', ''),
397 'ausfertigung': kopf.get('ausfertigung', ''),
398 'quelle': f'https://www.gesetze-im-internet.de/{kennung}/',
399 'abzug': abzugsdatum(pfad),
400 'normen': {
401 b: {
402 'titel': n.titel,
403 'ist_anlage': n.ist_anlage,
404 'absaetze': {
405 a.nummer: {'gliederung': a.gliederung, 'text': a.text}
406 for a in n.absaetze.values()
407 },
408 'text': n.text,
409 }
410 for b, n in normen.items()
411 },
412 }
413
414 gegliedert = sum(
415 1 for n in paragrafen.values() for a in n.absaetze.values() if a.gliederung
416 )
417 print(
418 f'{kuerzel:10} {len(paragrafen):4} Paragrafen, {len(anlagen)} Anlagen, '
419 f'{gegliedert:4} gegliederte Absaetze'
420 )
421
422 ergebnis = {
423 'hinweis': (
424 'Maschinell erzeugt aus dem amtlichen XML von gesetze-im-internet.de '
425 '(Bundesministerium der Justiz / juris GmbH). Nicht von Hand aendern - '
426 'neu erzeugen mit: python data-pipeline/gesetze_index.py'
427 ),
428 'gesetze': gesetze,
429 }
430
431 with io.open(ZIEL, 'w', encoding='utf-8', newline='\n') as datei_aus:
432 json.dump(ergebnis, datei_aus, ensure_ascii=False, indent=1)
433
434 print(f'\ngeschrieben: {ZIEL.name} ({ZIEL.stat().st_size / 1_048_576:.1f} MB)')
435
436 # Was beim Lesen nicht aufging, gehört ausgesprochen. Ein Absatz mit
437 # unlesbarer Gliederung fällt sonst niemandem auf – und an ihm scheitern
438 # anschliessend richtige Zitate.
439 if MARKENBEFUNDE:
440 print(f'\nUnsaubere Gliederungsmarken im amtlichen XML ({len(MARKENBEFUNDE)}):')
441 for befund in MARKENBEFUNDE:
442 print(f' {befund}')
443
444 return 0
445
446
447 if __name__ == '__main__':
448 raise SystemExit(main())