waffensachkunde

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

/ data-pipeline gesetze_index.py

15,4 KB Rohdatei
data-pipeline/gesetze_index.py — 414 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 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())