waffensachkunde

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

/ data-pipeline normtexte_bauen.py

11,4 KB Rohdatei
data-pipeline/normtexte_bauen.py — 292 Zeilen
1 """Baut ``content/normtexte.json`` – die Normtexte, die mitgeliefert werden.
2
3 Warum es diese Datei gibt
4 -------------------------
5 Jede Erklärungstafel schließt mit „Im Gesetz nachlesen" und einer Liste von
6 Fundstellen. Bis 0.22.0 waren das reine Textzitate ohne Sprungziel, und die
7 Anwendung lieferte keinen einzigen Normtext mit. Der Lernende einer
8 ausdrücklich vollständig offline arbeitenden Software konnte die Aufforderung
9 also gerade nicht offline erfüllen.
10
11 Die Datenbasis liegt seit jeher vor: ``content/gesetze/index.json`` ist die
12 Prüfgrundlage jeder einzelnen Fundstelle. Sie enthält die sieben Gesetze aber
13 **vollständig** – 914 Normen, darunter katalogfremde wie § 173 StGB. Diese
14 Datei hier ist der Auszug: nur die Normen, die in ``erklaerungen.json`` oder
15 ``glossar.json`` wirklich zitiert werden. Zitiert wird nichts hinzugefügt und
16 nichts umformuliert; Gesetzestexte sind nach § 5 Abs. 1 UrhG gemeinfrei.
17
18 Die Anlagen sind der schwierige Teil
19 ------------------------------------
20 632 der 2137 Fundstellen zeigen in eine Anlage, allein 391 in Anlage 1 des
21 WaffG – und die ist 29 000 Zeichen lang. Sie als eine Textwand anzuzeigen
22 hilft niemandem, am wenigsten mit Bildschirmleser.
23
24 Die Versuchung wäre, den zitierten Ausschnitt herauszuschneiden. Genau das
25 macht dieses Skript **nicht**. Ein falsch gesetzter Schnitt wäre ein
26 verfälschtes Gesetzeszitat in einer Lernsoftware für eine Rechtsprüfung – der
27 schlimmste Fehler, den diese Anwendung machen könnte. Stattdessen wird die
28 Anlage **verlustfrei** in ihre eigenen Gliederungsblöcke zerlegt: Aneinander
29 gehängt ergeben die Blöcke wieder Zeichen für Zeichen den Ausgangstext, und
30 genau das prüft das Skript, bevor es etwas schreibt. Die Blockmarken sind für
31 die Anwendung nur Sprungziele. Beschnitten wird nichts.
32
33 Aufruf
34 ------
35 python data-pipeline/normtexte_bauen.py
36 python data-pipeline/normtexte_bauen.py --selbsttest
37 """
38
39 from __future__ import annotations
40
41 import json
42 import re
43 import sys
44 from datetime import date, timezone, datetime
45 from pathlib import Path
46
47 WURZEL = Path(__file__).resolve().parent.parent
48 INDEX = WURZEL / 'content' / 'gesetze' / 'index.json'
49 ERKLAERUNGEN = WURZEL / 'content' / 'erklaerungen.json'
50 GLOSSAR = WURZEL / 'content' / 'glossar.json'
51 ZIEL = WURZEL / 'content' / 'normtexte.json'
52
53 #: Eine Gliederungsmarke am Zeilenanfang: „1.", „1.1", „2.3.4".
54 #: Die Anlagen setzen sie auf eine eigene Zeile, der Text folgt darunter.
55 MARKE = re.compile(r'^(\d+(?:\.\d+)*)\.?$')
56
57 #: „Abschnitt 1:", „Unterabschnitt 3:" – ebenfalls auf eigener Zeile.
58 GLIEDERUNGSZEILE = re.compile(r'^((?:Unter)?[Aa]bschnitt)\s+([0-9IVX]+)\s*:?$')
59
60 HINWEIS = (
61 'Maschinell erzeugter Auszug aus dem amtlichen XML von gesetze-im-internet.de '
62 '(Bundesministerium der Justiz / juris GmbH). Enthaelt ausschliesslich die Normen, '
63 'die in den Erklaerungen oder im Glossar dieser Software zitiert werden. Der '
64 'Wortlaut ist unveraendert; Gesetzestexte sind nach § 5 Abs. 1 UrhG gemeinfrei. '
65 'Nicht von Hand aendern - neu erzeugen mit: python data-pipeline/normtexte_bauen.py'
66 )
67
68
69 def zitierte_normen() -> dict[str, set[str]]:
70 """Alle Normen, die irgendwo zitiert werden – Gesetz -> Menge der Normnamen."""
71 zitiert: dict[str, set[str]] = {}
72
73 erklaerungen = json.loads(ERKLAERUNGEN.read_text(encoding='utf-8'))
74 for eintrag in erklaerungen['zuFrage'].values():
75 for fundstelle in eintrag.get('fundstellen', []):
76 zitiert.setdefault(fundstelle['gesetz'], set()).add(fundstelle['norm'])
77
78 glossar = json.loads(GLOSSAR.read_text(encoding='utf-8'))
79 for eintrag in glossar['eintraege']:
80 for fundstelle in eintrag.get('fundstellen', []):
81 zitiert.setdefault(fundstelle['gesetz'], set()).add(fundstelle['norm'])
82
83 return zitiert
84
85
86 def anlage_zerlegen(text: str) -> list[dict]:
87 """Zerlegt eine Anlage verlustfrei in ihre Gliederungsblöcke.
88
89 Jeder Block trägt die Marke, unter der er in der Anlage steht („1.3.1.3",
90 „Abschnitt 2"), und seinen Text. **Verlustfrei** heißt: Die Texte aller
91 Blöcke, in dieser Reihenfolge mit Zeilenumbruch verbunden, ergeben wieder
92 genau den Ausgangstext. Der Aufrufer prüft das, bevor etwas geschrieben
93 wird – ein beschnittenes Gesetzeszitat wäre der schlimmste Fehler, den
94 diese Anwendung machen kann.
95
96 Der Vorspann vor der ersten Marke bekommt ``marke = None``. Er fällt
97 dadurch nicht weg, er ist nur kein Sprungziel.
98
99 Jeder Block trägt zusätzlich seinen ``pfad`` – die Abschnitte, in denen er
100 steht. Das ist keine Zugabe, sondern notwendig: In Anlage 1 des WaffG
101 kommt die Marke „1.1" **vier**mal vor, in verschiedenen Unterabschnitten.
102 Ohne Pfad spränge eine Fundstelle in die falsche Stelle des Gesetzes, und
103 zwar unbemerkt.
104 """
105 bloecke: list[dict] = []
106 pfad: list[str] = []
107 laufend: dict = {'marke': None, 'pfad': [], 'zeilen': []}
108
109 for zeile in text.split('\n'):
110 roh = zeile.strip()
111 marke: str | None = None
112
113 treffer = MARKE.match(roh)
114 if treffer is not None:
115 marke = treffer.group(1)
116 else:
117 gliederung = GLIEDERUNGSZEILE.match(roh)
118 if gliederung is not None:
119 wort = gliederung.group(1).capitalize()
120 marke = f'{wort} {gliederung.group(2)}'
121 # „Abschnitt 2" beginnt von vorn, „Unterabschnitt 3" haengt
122 # sich an den laufenden Abschnitt.
123 pfad = [marke] if wort == 'Abschnitt' else [*pfad[:1], marke]
124
125 if marke is not None:
126 # Der bisherige Block ist zu Ende – auch ein leerer Vorspann wird
127 # aufgehoben, sonst ginge seine Zeile verloren.
128 if laufend['zeilen'] or laufend['marke'] is not None:
129 bloecke.append(laufend)
130 # Der Abschnittsblock selbst steht ueber seinem Pfad, nicht darin.
131 eigener = pfad[:-1] if marke == (pfad[-1] if pfad else None) else list(pfad)
132 laufend = {'marke': marke, 'pfad': eigener, 'zeilen': [zeile]}
133 else:
134 laufend['zeilen'].append(zeile)
135
136 if laufend['zeilen'] or laufend['marke'] is not None:
137 bloecke.append(laufend)
138
139 return [
140 {'marke': block['marke'], 'pfad': block['pfad'], 'text': '\n'.join(block['zeilen'])}
141 for block in bloecke
142 ]
143
144
145 def zusammensetzen(bloecke: list[dict]) -> str:
146 """Die Gegenprobe zu :func:`anlage_zerlegen`."""
147 return '\n'.join(block['text'] for block in bloecke)
148
149
150 def bauen() -> dict:
151 index = json.loads(INDEX.read_text(encoding='utf-8'))
152 zitiert = zitierte_normen()
153
154 gesetze: dict[str, dict] = {}
155 fehlend: list[str] = []
156 anlagen = 0
157 paragrafen = 0
158
159 for kuerzel in sorted(zitiert):
160 quelle = index['gesetze'].get(kuerzel)
161 if quelle is None:
162 fehlend.append(kuerzel)
163 continue
164
165 normen: dict[str, dict] = {}
166 for name in sorted(zitiert[kuerzel], key=sortierschluessel):
167 norm = quelle['normen'].get(name)
168 if norm is None:
169 fehlend.append(f'{kuerzel} {name}')
170 continue
171
172 if norm['ist_anlage']:
173 bloecke = anlage_zerlegen(norm['text'])
174 # Die Gegenprobe. Sie steht hier und nicht im Selbsttest:
175 # Sie muss fuer jede ausgelieferte Anlage gelten, nicht nur
176 # fuer ein Beispiel.
177 if zusammensetzen(bloecke) != norm['text']:
178 raise SystemExit(
179 f'ABBRUCH: Die Zerlegung von {kuerzel} {name} ist nicht verlustfrei. '
180 'Es wird nichts geschrieben.'
181 )
182 normen[name] = {
183 'titel': norm['titel'],
184 'istAnlage': True,
185 'bloecke': bloecke,
186 }
187 anlagen += 1
188 else:
189 # Alle Absaetze der zitierten Norm, nicht nur die zitierten:
190 # Wer einen Absatz nachschlaegt, liest oft den daneben mit -
191 # und eine halb ausgelieferte Norm waere eine zweite Sorte
192 # Luecke, die niemand erwartet.
193 normen[name] = {
194 'titel': norm['titel'],
195 'istAnlage': False,
196 'absaetze': {
197 nummer: absatz['text'] for nummer, absatz in norm['absaetze'].items()
198 },
199 }
200 # Normen ganz ohne Absatzgliederung tragen ihren Text im Feld
201 # „text"; ohne diesen Zweig kaemen sie leer heraus.
202 if not normen[name]['absaetze']:
203 normen[name]['text'] = norm['text']
204 paragrafen += 1
205
206 gesetze[kuerzel] = {
207 'bezeichnung': quelle['bezeichnung'],
208 'stand': quelle['stand'],
209 'quelle': quelle['quelle'],
210 'normen': normen,
211 }
212
213 if fehlend:
214 raise SystemExit(f'ABBRUCH: nicht im Index gefunden: {", ".join(fehlend)}')
215
216 print(f'{paragrafen} Paragrafen und {anlagen} Anlagen aus {len(gesetze)} Gesetzen.')
217
218 return {
219 'meta': {
220 'version': 1,
221 'stand': date.today().isoformat(),
222 'hinweis': HINWEIS,
223 'gesetzesstand': {k: g['stand'] for k, g in gesetze.items()},
224 },
225 'gesetze': gesetze,
226 }
227
228
229 def sortierschluessel(name: str) -> tuple:
230 """Normen in ihrer natürlichen Reihenfolge: § 2 vor § 10, Anlagen zuletzt."""
231 treffer = re.match(r'^§ (\d+)([a-z]*)$', name)
232 if treffer is not None:
233 return (0, int(treffer.group(1)), treffer.group(2))
234 return (1, 0, name)
235
236
237 def selbsttest() -> int:
238 """Prüft die Zerlegung an Fällen, deren Ergebnis von Hand feststeht."""
239 fehler = 0
240
241 def pruefe(name: str, bedingung: bool) -> None:
242 nonlocal fehler
243 if not bedingung:
244 print(f' FEHLER: {name}')
245 fehler += 1
246 else:
247 print(f' ok: {name}')
248
249 text = 'Vorspann\nAbschnitt 1:\nÜberschrift\n1.1\nErster Satz.\n1.2\nZweiter Satz.'
250 bloecke = anlage_zerlegen(text)
251 pruefe('verlustfrei', zusammensetzen(bloecke) == text)
252 pruefe('Vorspann bleibt erhalten', bloecke[0]['marke'] is None)
253 pruefe('Abschnitt wird erkannt', bloecke[1]['marke'] == 'Abschnitt 1')
254 pruefe('Nummern werden erkannt', [b['marke'] for b in bloecke[2:]] == ['1.1', '1.2'])
255 pruefe(
256 'die Marke bleibt im Text stehen',
257 bloecke[2]['text'].startswith('1.1'),
258 )
259
260 tief = '1.3\nEbene zwei\n1.3.1.3\nEbene vier'
261 pruefe('tiefe Nummern', [b['marke'] for b in anlage_zerlegen(tief)] == ['1.3', '1.3.1.3'])
262
263 # Eine Zeile, die nur zufaellig mit einer Zahl beginnt, ist keine Marke.
264 kein = '1. Januar 2026 gilt Folgendes\nWeiter im Text'
265 pruefe('kein Fehlalarm bei Fliesstext', anlage_zerlegen(kein)[0]['marke'] is None)
266
267 leer = ''
268 pruefe('leerer Text bleibt verlustfrei', zusammensetzen(anlage_zerlegen(leer)) == leer)
269
270 return fehler
271
272
273 def main() -> int:
274 if '--selbsttest' in sys.argv:
275 fehler = selbsttest()
276 print('Selbsttest bestanden.' if fehler == 0 else f'{fehler} Fehler.')
277 return 1 if fehler else 0
278
279 daten = bauen()
280 ZIEL.write_text(
281 json.dumps(daten, ensure_ascii=False, indent=1) + '\n',
282 encoding='utf-8',
283 # LF wie im Archiv verlangt, nicht das CRLF von Windows.
284 newline='\n',
285 )
286 groesse = ZIEL.stat().st_size
287 print(f'Geschrieben: {ZIEL.relative_to(WURZEL)} ({groesse / 1024:.0f} KiB)')
288 return 0
289
290
291 if __name__ == '__main__':
292 raise SystemExit(main())