waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
/ data-pipeline normtexte_bauen.py
| 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 2138 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()) |