waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
/ data-pipeline erklaerungen_zusammenfuehren.py
| 1 | """Führt einzeln erarbeitete Erklärungen zu `content/erklaerungen.json` zusammen. |
| 2 | |
| 3 | Die Erklärungen entstehen abschnittweise in Teilstücken. Dieses Skript legt |
| 4 | sie zusammen, setzt den Kopf mit dem tatsächlichen Gesetzesstand und schreibt |
| 5 | das Ergebnis in einer festen Reihenfolge – so bleibt der Unterschied zwischen |
| 6 | zwei Ständen im Versionsverlauf lesbar und besteht nicht aus umsortierten |
| 7 | Zeilen. |
| 8 | |
| 9 | Ein Teilstück ist eine JSON-Datei mit denselben Einträgen wie `zuFrage`, |
| 10 | entweder unmittelbar:: |
| 11 | |
| 12 | { "I.1-01": { … }, "I.1-02": { … } } |
| 13 | |
| 14 | oder in einen Umschlag gepackt:: |
| 15 | |
| 16 | { "zuFrage": { "I.1-01": { … } } } |
| 17 | |
| 18 | Aufruf |
| 19 | ------ |
| 20 | python data-pipeline/erklaerungen_zusammenfuehren.py <datei-oder-verzeichnis> … |
| 21 | |
| 22 | Vorhandene Erklärungen bleiben erhalten; gleichnamige Einträge aus einem |
| 23 | Teilstück ersetzen sie. Mit `--nur-neu` bleibt der Bestand unangetastet und |
| 24 | es werden ausschließlich fehlende Einträge ergänzt. |
| 25 | |
| 26 | Nach dem Zusammenführen läuft die Prüfung – ohne sie wird nicht geschrieben. |
| 27 | """ |
| 28 | |
| 29 | from __future__ import annotations |
| 30 | |
| 31 | import argparse |
| 32 | import io |
| 33 | import json |
| 34 | import sys |
| 35 | from pathlib import Path |
| 36 | |
| 37 | WURZEL = Path(__file__).resolve().parent.parent |
| 38 | GESETZE = WURZEL / 'content' / 'gesetze' / 'index.json' |
| 39 | KATALOG = WURZEL / 'content' / 'katalog' / 'katalog.json' |
| 40 | ZIEL = WURZEL / 'content' / 'erklaerungen.json' |
| 41 | ZWEIFEL = WURZEL / 'docs' / 'erklaerungen-zweifel.md' |
| 42 | |
| 43 | #: Ablage der Redaktionsvermerke - **nicht** ausgeliefert. |
| 44 | #: |
| 45 | #: Sie braucht einen eigenen Ort, und das ist der Kern der Sache. Bis Fassung |
| 46 | #: 0.24.1 wurden die Vermerke aus dem Bestand herausgenommen und nur nach |
| 47 | #: `ZWEIFEL` geschrieben; jener Lauf ueberschrieb die Datei vollstaendig, und |
| 48 | #: beim naechsten Durchgang war der Vermerk nirgends mehr - weder im Bestand |
| 49 | #: noch in der Datei. Sechs von zwoelf gingen so verloren, nachweisbar in der |
| 50 | #: Projektgeschichte. |
| 51 | #: |
| 52 | #: `content/erklaerungen.json` kommt als Ablage nicht in Frage: Sie wird |
| 53 | #: ausgeliefert, und die Vermerke sollen Lernende ausdruecklich nicht |
| 54 | #: erreichen. Deshalb diese Datei hier neben dem Werkzeug. |
| 55 | ZWEIFEL_ABLAGE = WURZEL / 'data-pipeline' / 'zweifel.json' |
| 56 | |
| 57 | #: Muss Wort fuer Wort dem meta.hinweis in content/erklaerungen.json |
| 58 | #: entsprechen - ein Zusammenfuehren schreibt diesen Text dorthin zurueck und |
| 59 | #: wuerde eine Praezisierung sonst stillschweigend wieder einkassieren. |
| 60 | HINWEIS = ( |
| 61 | 'Eigener redaktioneller Inhalt, nicht Teil des amtlichen Fragenkatalogs des ' |
| 62 | 'Bundesverwaltungsamtes. Jede Fundstelle wird von ' |
| 63 | 'data-pipeline/pruefe_erklaerungen.py gegen den amtlichen Gesetzestext von ' |
| 64 | 'gesetze-im-internet.de geprueft: nachgewiesen werden Existenz der Norm und ' |
| 65 | 'jede angegebene Feinstelle (Absatz, Nummer, Buchstabe, Anlagen-Stelle; ' |
| 66 | 'Satzangaben als Plausibilitaetsgrenze, da das amtliche XML Saetze nicht ' |
| 67 | 'auszeichnet). Ob eine Norm die Aussage inhaltlich traegt, prueft die ' |
| 68 | 'Redaktion, nicht die Maschine.' |
| 69 | ) |
| 70 | |
| 71 | |
| 72 | def teilstuecke_einlesen(orte: list[str]) -> dict[str, dict]: |
| 73 | """Liest alle angegebenen Dateien und Verzeichnisse ein.""" |
| 74 | gesammelt: dict[str, dict] = {} |
| 75 | |
| 76 | dateien: list[Path] = [] |
| 77 | for ort in orte: |
| 78 | pfad = Path(ort) |
| 79 | if pfad.is_dir(): |
| 80 | dateien.extend(sorted(pfad.glob('*.json'))) |
| 81 | elif pfad.is_file(): |
| 82 | dateien.append(pfad) |
| 83 | else: |
| 84 | raise SystemExit(f'Nicht gefunden: {ort}') |
| 85 | |
| 86 | for datei in dateien: |
| 87 | if datei.resolve() == ZIEL.resolve(): |
| 88 | continue # das Ziel ist kein Teilstück |
| 89 | roh = json.load(io.open(datei, encoding='utf-8')) |
| 90 | eintraege = roh.get('zuFrage') if isinstance(roh, dict) and 'zuFrage' in roh else roh |
| 91 | if not isinstance(eintraege, dict): |
| 92 | raise SystemExit(f'{datei}: erwartet wird ein Objekt mit Frage-Kennungen.') |
| 93 | |
| 94 | for frage_id, erklaerung in eintraege.items(): |
| 95 | if frage_id in gesammelt: |
| 96 | print(f' Hinweis: {frage_id} kommt mehrfach vor – {datei.name} gilt.') |
| 97 | gesammelt[frage_id] = erklaerung |
| 98 | print(f' {datei.name}: {len(eintraege)} Erklärungen') |
| 99 | |
| 100 | return gesammelt |
| 101 | |
| 102 | |
| 103 | #: Felder einer Fundstelle, die als Zeichenkette vorliegen müssen. |
| 104 | FUNDSTELLENFELDER = ('gesetz', 'norm', 'absatz', 'nummer', 'buchstabe', 'satz', 'stelle') |
| 105 | |
| 106 | |
| 107 | def fundstellen_normalisieren(bestand: dict[str, dict]) -> int: |
| 108 | """Macht aus Zahlen Zeichenketten. |
| 109 | |
| 110 | `"absatz": 2` statt `"absatz": "2"` ist eine naheliegende Verwechslung und |
| 111 | inhaltlich harmlos – der Lader in der Anwendung weist sie aber ab, weil er |
| 112 | auf den Typ prüft. Statt das jedes Mal von Hand zu berichtigen, wird hier |
| 113 | einmal umgesetzt: an der Stelle, an der die Teilstücke ohnehin |
| 114 | zusammengeführt werden. |
| 115 | |
| 116 | Bewusst nur Zahlen. Alles andere bleibt stehen und fällt der Prüfung auf. |
| 117 | """ |
| 118 | geaendert = 0 |
| 119 | for erklaerung in bestand.values(): |
| 120 | for fundstelle in erklaerung.get('fundstellen') or []: |
| 121 | if not isinstance(fundstelle, dict): |
| 122 | continue |
| 123 | for feld in FUNDSTELLENFELDER: |
| 124 | wert = fundstelle.get(feld) |
| 125 | if isinstance(wert, bool): |
| 126 | continue # bool ist in Python eine Zahl – hier keine |
| 127 | if isinstance(wert, (int, float)): |
| 128 | fundstelle[feld] = str(wert) |
| 129 | geaendert += 1 |
| 130 | return geaendert |
| 131 | |
| 132 | |
| 133 | def sortierschluessel(frage_id: str, reihenfolge: dict[str, int]) -> tuple[int, str]: |
| 134 | """Katalogreihenfolge, Unbekanntes ans Ende.""" |
| 135 | return (reihenfolge.get(frage_id, len(reihenfolge)), frage_id) |
| 136 | |
| 137 | |
| 138 | def main() -> int: |
| 139 | zerleger = argparse.ArgumentParser(description='Erklärungen zusammenführen.') |
| 140 | zerleger.add_argument('quellen', nargs='+', help='Dateien oder Verzeichnisse') |
| 141 | zerleger.add_argument( |
| 142 | '--nur-neu', |
| 143 | action='store_true', |
| 144 | help='Vorhandene Einträge nicht ersetzen, nur fehlende ergänzen', |
| 145 | ) |
| 146 | argumente = zerleger.parse_args() |
| 147 | |
| 148 | print('Teilstücke:') |
| 149 | neue = teilstuecke_einlesen(argumente.quellen) |
| 150 | |
| 151 | bestand: dict[str, dict] = {} |
| 152 | if ZIEL.exists(): |
| 153 | bestand = json.load(io.open(ZIEL, encoding='utf-8')).get('zuFrage') or {} |
| 154 | |
| 155 | vorher = len(bestand) |
| 156 | ersetzt = 0 |
| 157 | for frage_id, erklaerung in neue.items(): |
| 158 | if frage_id in bestand: |
| 159 | if argumente.nur_neu: |
| 160 | continue |
| 161 | ersetzt += 1 |
| 162 | bestand[frage_id] = erklaerung |
| 163 | |
| 164 | normalisiert = fundstellen_normalisieren(bestand) |
| 165 | if normalisiert: |
| 166 | print(f' {normalisiert} Zahlangaben in Fundstellen zu Zeichenketten gemacht.') |
| 167 | |
| 168 | katalog = json.load(io.open(KATALOG, encoding='utf-8')) |
| 169 | reihenfolge = {f['id']: i for i, f in enumerate(katalog['fragen'])} |
| 170 | |
| 171 | # Zweifel an der amtlichen Loesung sind eine Notiz fuer die Redaktion und |
| 172 | # gehoeren nicht in die Auslieferung: Sie wuerden Lernende verunsichern, |
| 173 | # ohne dass sie an der Pruefung etwas aendern koennten. Verloren gehen |
| 174 | # duerfen sie trotzdem nicht - sie sind das Wertvollste am Durchgang. |
| 175 | # |
| 176 | # Genau das ist bis Fassung 0.24.1 geschehen. Hier stand `pop`: Der |
| 177 | # Vermerk wurde aus dem Bestand HERAUSGENOMMEN und nur in die |
| 178 | # Markdown-Datei geschrieben, die anschliessend vollstaendig ueberschrieben |
| 179 | # wird. Beim naechsten Lauf war er im Bestand nicht mehr da, also stand er |
| 180 | # auch nicht mehr in der Datei. Die Projektgeschichte zeigt es Schritt fuer |
| 181 | # Schritt: erst I.1-83, dann nur noch I.2-21, dann nur noch vier aus |
| 182 | # Kapitel II/III, zuletzt nur noch die sechs des letzten Durchgangs. |
| 183 | # Sechs von zwoelf Vermerken waren damit fort. |
| 184 | # |
| 185 | # Jetzt bleibt der Vermerk im Bestand stehen; herausgefiltert wird er erst |
| 186 | # beim Schreiben von erklaerungen.json (siehe `ohne_zweifel`). |
| 187 | zweifel = zweifel_zusammenfuehren(bestand) |
| 188 | zweifel_ablegen(zweifel) |
| 189 | zweifel_schreiben(zweifel, katalog) |
| 190 | gesetze = json.load(io.open(GESETZE, encoding='utf-8'))['gesetze'] |
| 191 | |
| 192 | # Nur die Gesetze vermerken, die auch zitiert werden – ein Stand, auf den |
| 193 | # sich nichts bezieht, wäre eine Behauptung ohne Gegenstand. |
| 194 | zitiert = { |
| 195 | f.get('gesetz') |
| 196 | for e in bestand.values() |
| 197 | for f in (e.get('fundstellen') or []) |
| 198 | if isinstance(f, dict) |
| 199 | } |
| 200 | |
| 201 | ergebnis = { |
| 202 | 'meta': { |
| 203 | 'version': 1, |
| 204 | 'stand': heute(), |
| 205 | 'hinweis': HINWEIS, |
| 206 | 'gesetzesstand': { |
| 207 | k: gesetze[k]['stand'] for k in gesetze if k in zitiert |
| 208 | }, |
| 209 | }, |
| 210 | 'zuFrage': { |
| 211 | frage_id: ohne_zweifel(bestand[frage_id]) |
| 212 | for frage_id in sorted(bestand, key=lambda k: sortierschluessel(k, reihenfolge)) |
| 213 | }, |
| 214 | } |
| 215 | |
| 216 | with io.open(ZIEL, 'w', encoding='utf-8', newline='\n') as datei: |
| 217 | json.dump(ergebnis, datei, ensure_ascii=False, indent=1) |
| 218 | datei.write('\n') |
| 219 | |
| 220 | gesamt = len(katalog['fragen']) |
| 221 | print( |
| 222 | f'\nvorher {vorher}, neu {len(bestand) - vorher}, ersetzt {ersetzt} ' |
| 223 | f'-> {len(bestand)} von {gesamt} Fragen ({len(bestand) / gesamt * 100:.1f} %)' |
| 224 | ) |
| 225 | print(f'geschrieben: {ZIEL.name}') |
| 226 | print('\nJetzt prüfen mit: python data-pipeline/pruefe_erklaerungen.py') |
| 227 | return 0 |
| 228 | |
| 229 | |
| 230 | def zweifel_schreiben(zweifel: dict[str, str], katalog: dict) -> None: |
| 231 | """Haelt Zweifel an der amtlichen Loesung als Liste zur Durchsicht fest.""" |
| 232 | if not zweifel: |
| 233 | return |
| 234 | |
| 235 | fragen = {f['id']: f for f in katalog['fragen']} |
| 236 | zeilen = [ |
| 237 | '# Zweifel an amtlichen Loesungen', |
| 238 | '', |
| 239 | 'Beim Schreiben der Erklaerungen aufgefallen: Stellen, an denen der', |
| 240 | 'amtliche Fragenkatalog von der abgerufenen Fassung des Gesetzes', |
| 241 | 'abweicht oder in sich unstimmig wirkt.', |
| 242 | '', |
| 243 | 'Diese Vermerke werden **nicht** ausgeliefert. Die Erklaerungen folgen', |
| 244 | 'durchgaengig der amtlichen Loesung - in der Pruefung wird danach', |
| 245 | 'bewertet. Die Liste dient der Redaktion und einer moeglichen Rueckmeldung', |
| 246 | 'an das Bundesverwaltungsamt.', |
| 247 | '', |
| 248 | 'Maschinell erzeugt von data-pipeline/erklaerungen_zusammenfuehren.py.', |
| 249 | '', |
| 250 | ] |
| 251 | for frage_id in sorted(zweifel, key=lambda k: (fragen.get(k, {}).get('seite', 0), k)): |
| 252 | frage = fragen.get(frage_id, {}) |
| 253 | zeilen.append(f'## {frage_id} (amtliche Nr. {frage.get("amtliche_nummer", "?")})') |
| 254 | zeilen.append('') |
| 255 | zeilen.append(f'**Frage:** {frage.get("frage", {}).get("text", "")}') |
| 256 | zeilen.append('') |
| 257 | zeilen.append(zweifel[frage_id]) |
| 258 | zeilen.append('') |
| 259 | |
| 260 | with io.open(ZWEIFEL, 'w', encoding='utf-8', newline='\n') as datei: |
| 261 | datei.write('\n'.join(zeilen)) |
| 262 | print(f'{len(zweifel)} Zweifel vermerkt in {ZWEIFEL.name}') |
| 263 | |
| 264 | |
| 265 | def zweifel_zusammenfuehren(bestand: dict[str, dict]) -> dict[str, str]: |
| 266 | """Die Vermerke aus der Ablage und aus dem frisch Eingelesenen. |
| 267 | |
| 268 | Was in einem Teilstueck steht, gewinnt: Wer einen Vermerk neu formuliert, |
| 269 | will die neue Fassung. Was nur in der Ablage steht, bleibt erhalten - das |
| 270 | ist der ganze Zweck der Ablage. |
| 271 | """ |
| 272 | gesammelt: dict[str, str] = {} |
| 273 | if ZWEIFEL_ABLAGE.exists(): |
| 274 | gesammelt.update(json.load(io.open(ZWEIFEL_ABLAGE, encoding='utf-8')).get('zuFrage') or {}) |
| 275 | for frage_id, erklaerung in bestand.items(): |
| 276 | vermerk = erklaerung.get('zweifel') |
| 277 | if vermerk: |
| 278 | gesammelt[frage_id] = vermerk |
| 279 | return gesammelt |
| 280 | |
| 281 | |
| 282 | def zweifel_ablegen(zweifel: dict[str, str]) -> None: |
| 283 | """Schreibt die Ablage - die Quelle, aus der die Liste jedesmal entsteht.""" |
| 284 | ergebnis = { |
| 285 | 'hinweis': ( |
| 286 | 'Redaktionsvermerke zu Zweifeln an amtlichen Loesungen. Wird NICHT ' |
| 287 | 'ausgeliefert. Erzeugt und fortgeschrieben von ' |
| 288 | 'data-pipeline/erklaerungen_zusammenfuehren.py; die lesbare Fassung ' |
| 289 | 'steht in docs/erklaerungen-zweifel.md.' |
| 290 | ), |
| 291 | 'zuFrage': dict(sorted(zweifel.items())), |
| 292 | } |
| 293 | with io.open(ZWEIFEL_ABLAGE, 'w', encoding='utf-8', newline='\n') as datei: |
| 294 | json.dump(ergebnis, datei, ensure_ascii=False, indent=1) |
| 295 | datei.write('\n') |
| 296 | |
| 297 | |
| 298 | def ohne_zweifel(erklaerung: dict) -> dict: |
| 299 | """Die Erklaerung, wie sie ausgeliefert wird - ohne den Redaktionsvermerk. |
| 300 | |
| 301 | Herausgefiltert statt herausgenommen: Im Bestand bleibt der Vermerk |
| 302 | stehen, damit er den naechsten Durchgang ueberlebt. Siehe `main()`. |
| 303 | """ |
| 304 | return {schluessel: wert for schluessel, wert in erklaerung.items() if schluessel != 'zweifel'} |
| 305 | |
| 306 | |
| 307 | def heute() -> str: |
| 308 | """Datum als ISO-Zeichenkette.""" |
| 309 | from datetime import date |
| 310 | |
| 311 | return date.today().isoformat() |
| 312 | |
| 313 | |
| 314 | if __name__ == '__main__': |
| 315 | sys.stdout.reconfigure(encoding='utf-8', errors='replace') # type: ignore[union-attr] |
| 316 | raise SystemExit(main()) |