waffensachkunde

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

/ data-pipeline gesetze_index.py

14,1 KB Rohdatei
data-pipeline/gesetze_index.py — 391 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 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())