waffensachkunde

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

/ data-pipeline erklaerungen_zusammenfuehren.py

12,4 KB Rohdatei
data-pipeline/erklaerungen_zusammenfuehren.py — 316 Zeilen
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())