waffensachkunde

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

/ data-pipeline pruefe_glossar.py

26,7 KB Rohdatei
data-pipeline/pruefe_glossar.py — 642 Zeilen
1 """Prüft das Glossar gegen Katalog und amtlichen Gesetzestext.
2
3 Dieselbe Zusage wie bei den Erklärungen: Was als Fundstelle dasteht, muss im
4 Gesetz nachweisbar sein. Die Prüfung der Fundstellen kommt deshalb wörtlich
5 aus `pruefe_erklaerungen.py` – zwei Kopien derselben Regel würden mit der
6 Zeit auseinanderlaufen, und dann gälte für das Glossar ein anderer Maßstab
7 als für die Erklärungen.
8
9 Zusätzlich glossarspezifisch geprüft
10 ------------------------------------
11 1. **Eindeutigkeit** – kein Begriff zweimal.
12 2. **Wortformen** – gültige Zeichenketten. Ein Eintrag ohne Formen ist
13 zulässig: Er erscheint dann nur in der Glossaransicht und an keiner Frage.
14 Das ist bei Abkürzungen normal, die nur in den Erklärungen vorkommen.
15 3. **Querverweise** – jedes `siehe` zeigt auf einen Eintrag, den es gibt.
16 4. **Bezug zum Text** – kommt eine Wortform in den Fragen oder Erklärungen
17 überhaupt vor? Das ist kein Fehler, aber ein Hinweis: Ein Glossar aus
18 Wörtern, die niemand liest, hilft niemandem. Durchsucht werden beide,
19 denn Abkürzungen wie WBK oder NWR stehen nur in den Erklärungen.
20 5. **Form** – Länge von `begriff`, `kurz`, `text` und `ohneFundstelleGrund`,
21 kein Leerraum am Rand, `art` ist `begriff` oder `abkuerzung`, keine
22 unbekannten Felder.
23 6. **Redliche Lücken** – ein Eintrag ohne Fundstelle muss begründen, warum es
24 keine gibt; eine Begründung neben vorhandenen Fundstellen ist umgekehrt ein
25 Widerspruch. Damit ist das Fehlen eine Aussage und keine Nachlässigkeit.
26 7. **Gesetzesstand** – der im Kopf vermerkte Stand jedes Gesetzes muss zum
27 Index passen. Sonst wäre gegen etwas anderes geprüft worden, als
28 ausgewiesen ist.
29
30 Die Punkte 5 bis 7 standen bis Fassung 0.21.0 nicht hier, obwohl das Skript sie
31 prüfte. Sie sind nachgetragen, weil der Selbsttest unten zusagt, *jede* hier
32 genannte Regel zu verletzen – eine unvollständige Liste machte diese Zusage
33 wieder wertlos.
34
35 Nicht geprüft wird, ob der Kopf die Pflichtfelder überhaupt trägt; bei den
36 Erklärungen tut das `alles_pruefen` dort, hier nicht.
37
38 Selbsttest
39 ----------
40 `--selbsttest` prüft die Prüfung: Jede der oben genannten Regeln wird einmal
41 absichtlich verletzt, und der Lauf besteht nur, wenn jede Verletzung auch
42 beanstandet wird. Dazu die Gegenprobe – der ausgelieferte Bestand darf keinen
43 Fehlalarm auslösen. Eine Prüfung, die nichts beanstandet, ist von einer
44 funktionierenden nicht zu unterscheiden.
45
46 Genau das war hier der Fall: Die Fahne stand im Modulkopf, `main()` hat
47 `sys.argv` aber nie gelesen. Der Aufruf mit `--selbsttest` fuhr stumm dieselbe
48 Bestandsprüfung und meldete Rückgabewert 0 – wer ihn in eine Prüfkette hängte,
49 führte dieselbe Prüfung zweimal aus und glaubte, die Prüfung sei geprüft.
50
51 Die geerbte Fundstellenprüfung deckt `pruefe_erklaerungen.py --selbsttest`
52 ab; hier stehen dafür nur zwei Fälle, die belegen, dass sie von einem
53 Glossareintrag aus überhaupt erreicht wird.
54
55 Aufruf
56 ------
57 python data-pipeline/pruefe_glossar.py # Bestand prüfen
58 python data-pipeline/pruefe_glossar.py --selbsttest # Prüfung prüfen
59 """
60
61 from __future__ import annotations
62
63 import io
64 import json
65 import re
66 import sys
67 from pathlib import Path
68
69 from pruefe_erklaerungen import GESETZE, KATALOG, Pruefung
70
71 WURZEL = Path(__file__).resolve().parent.parent
72 GLOSSAR = WURZEL / 'content' / 'glossar.json'
73 ERKLAERUNGEN_DATEI = WURZEL / 'content' / 'erklaerungen.json'
74
75 MIN_KURZ, MAX_KURZ = 15, 400
76 MIN_TEXT, MAX_TEXT = 80, 2000
77 ARTEN = ('begriff', 'abkuerzung')
78
79 #: Zeichen, die in einem deutschen Wort vorkommen – wie in shared/glossar.ts.
80 WORTZEICHEN = 'A-Za-zÄÖÜäöüßẞ0-9'
81
82
83 def wortform_kommt_vor(form: str, korpus: str) -> bool:
84 """Kommt die Wortform als eigenes Wort im Katalog vor?
85
86 Dieselbe Wortgrenze wie in der Anwendung: `\\b` taugt nicht, weil es nur
87 ASCII-Wortzeichen kennt und in „Schießstätte" an jedem ß eine Grenze
88 sähe.
89 """
90 muster = rf'(?<![{WORTZEICHEN}]){re.escape(form)}(?![{WORTZEICHEN}])'
91 return re.search(muster, korpus, re.IGNORECASE) is not None
92
93
94 def korpus_bilden() -> str:
95 """Alles, was der Nutzer liest: Katalog und Erklärungen.
96
97 Abkürzungen wie WBK, NWR oder VDMA kommen im amtlichen Katalog nicht vor,
98 sehr wohl aber in den Erklärungen. Für WCAG 3.1.4 zählt, was angezeigt
99 wird, nicht nur der Fragenwortlaut.
100 """
101 katalog = json.load(io.open(KATALOG, encoding='utf-8'))
102 teile: list[str] = []
103 for frage in katalog['fragen']:
104 teile.append(frage['frage']['text'])
105 muster = frage.get('musterantwort')
106 if muster:
107 teile.append(muster['text'])
108 for option in frage.get('optionen') or []:
109 teile.append(option['inhalt']['text'])
110
111 if ERKLAERUNGEN_DATEI.exists():
112 erklaerungen = json.load(io.open(ERKLAERUNGEN_DATEI, encoding='utf-8'))
113 for eintrag in (erklaerungen.get('zuFrage') or {}).values():
114 teile.append(eintrag.get('kurz', ''))
115 teile.append(eintrag.get('text', ''))
116 teile.append(eintrag.get('merksatz', '') or '')
117
118 return ' '.join(teile)
119
120
121 class Glossarpruefung(Pruefung):
122 """Erbt die Fundstellenprüfung und ergänzt, was nur das Glossar betrifft."""
123
124 def __init__(self, katalog: dict, gesetze: dict) -> None:
125 super().__init__(katalog, gesetze)
126 #: Wortformen ohne Vorkommen. Kein Fehler, nur ein Hinweis.
127 self.ohne_vorkommen: list[str] = []
128 #: Eintraege ohne Wortformen – sie erscheinen nur in der Glossaransicht.
129 self.nur_im_glossar: list[str] = []
130
131 def text_feld(self, wo: str, feld: str, wert: object, unten: int, oben: int) -> None:
132 if not isinstance(wert, str) or not wert.strip():
133 self.melden(wo, feld, 'fehlt oder ist leer')
134 return
135 if len(wert) < unten:
136 self.melden(wo, feld, f'zu kurz ({len(wert)} Zeichen, mindestens {unten})')
137 if len(wert) > oben:
138 self.melden(wo, feld, f'zu lang ({len(wert)} Zeichen, höchstens {oben})')
139 if wert != wert.strip():
140 self.melden(wo, feld, 'beginnt oder endet mit Leerraum')
141
142 def eintrag_pruefen(self, eintrag: dict, bekannt: set[str], korpus: str) -> None:
143 begriff = eintrag.get('begriff')
144 wo = str(begriff) if isinstance(begriff, str) and begriff else '(ohne Begriff)'
145
146 erlaubt = {
147 'begriff', 'art', 'kurz', 'text', 'varianten',
148 'fundstellen', 'ohneFundstelleGrund', 'siehe',
149 }
150 for feld in set(eintrag) - erlaubt:
151 self.melden(wo, feld, 'Unbekanntes Feld')
152
153 self.text_feld(wo, 'begriff', begriff, 2, 60)
154 self.text_feld(wo, 'kurz', eintrag.get('kurz'), MIN_KURZ, MAX_KURZ)
155
156 if 'text' in eintrag:
157 self.text_feld(wo, 'text', eintrag['text'], MIN_TEXT, MAX_TEXT)
158
159 art = eintrag.get('art')
160 if art not in ARTEN:
161 self.melden(wo, 'art', f'muss {" oder ".join(ARTEN)} sein, ist: {art!r}')
162
163 varianten = eintrag.get('varianten')
164 if not isinstance(varianten, list):
165 self.melden(wo, 'varianten', 'fehlt oder ist keine Liste')
166 elif not varianten:
167 # Kein Fehler: Der Eintrag steht dann nur im Glossar und wird
168 # an keiner Frage angezeigt. Das ist bei Abkuerzungen normal, die
169 # nur in den Erklaerungen vorkommen.
170 self.nur_im_glossar.append(wo)
171 else:
172 for form in varianten:
173 if not isinstance(form, str) or not form.strip():
174 self.melden(wo, 'varianten', f'ungültige Wortform: {form!r}')
175 elif not wortform_kommt_vor(form, korpus):
176 self.ohne_vorkommen.append(f'{wo}: „{form}"')
177
178 for verweis in eintrag.get('siehe') or []:
179 if verweis not in bekannt:
180 self.melden(wo, 'siehe', f'verweist auf „{verweis}“ – diesen Eintrag gibt es nicht')
181
182 fundstellen = eintrag.get('fundstellen')
183 if not isinstance(fundstellen, list):
184 self.melden(wo, 'fundstellen', 'fehlt oder ist keine Liste')
185 return
186
187 for index, fundstelle in enumerate(fundstellen):
188 if not isinstance(fundstelle, dict):
189 self.melden(wo, f'fundstelle[{index}]', 'ist kein Objekt')
190 continue
191 self.fundstelle_pruefen(wo, index, fundstelle)
192
193 grund = eintrag.get('ohneFundstelleGrund')
194 if not fundstellen:
195 if grund is None:
196 self.melden(
197 wo, 'fundstellen', 'Ohne Fundstelle muss „ohneFundstelleGrund" gesetzt sein'
198 )
199 else:
200 self.text_feld(wo, 'ohneFundstelleGrund', grund, 25, 400)
201 elif grund is not None:
202 self.melden(wo, 'ohneFundstelleGrund', 'gesetzt, obwohl Fundstellen vorhanden sind')
203
204 def alles_pruefen(self, daten: dict, korpus: str) -> None: # type: ignore[override]
205 meta = daten.get('meta')
206 if not isinstance(meta, dict):
207 self.melden('(meta)', 'meta', 'fehlt')
208 else:
209 for kuerzel, stand in (meta.get('gesetzesstand') or {}).items():
210 if kuerzel not in self.gesetze:
211 self.melden('(meta)', 'gesetzesstand', f'Unbekanntes Gesetz: {kuerzel}')
212 elif self.gesetze[kuerzel]['stand'] != stand:
213 self.melden(
214 '(meta)',
215 'gesetzesstand',
216 f'{kuerzel}: vermerkt „{stand}", Index sagt '
217 f'„{self.gesetze[kuerzel]["stand"]}"',
218 )
219
220 eintraege = daten.get('eintraege')
221 if not isinstance(eintraege, list):
222 self.melden('(datei)', 'eintraege', 'fehlt oder ist keine Liste')
223 return
224
225 gesehen: set[str] = set()
226 bekannt = {
227 e['begriff'] for e in eintraege if isinstance(e, dict) and isinstance(e.get('begriff'), str)
228 }
229
230 for eintrag in eintraege:
231 if not isinstance(eintrag, dict):
232 self.melden('(datei)', 'eintraege', 'Eintrag ist kein Objekt')
233 continue
234 begriff = eintrag.get('begriff')
235 if isinstance(begriff, str):
236 if begriff in gesehen:
237 self.melden(begriff, 'begriff', 'kommt mehrfach vor')
238 gesehen.add(begriff)
239 self.eintrag_pruefen(eintrag, bekannt, korpus)
240
241
242 # ── Selbsttest ───────────────────────────────────────────────────────────────
243
244 #: Ein gültiger Eintrag als Ausgangspunkt; jeder Fall unten ändert genau eines.
245 #:
246 #: „Schießstätte" ist mit Bedacht gewählt: Das Wort steht im Katalog und trägt
247 #: ein ß. Liefe die Wortsuche wieder über `\\b`, sähe sie dort eine Wortgrenze
248 #: und fände die Form nicht mehr – die glückliche Bahn am Ende schlägt dann an.
249 GUELTIGER_EINTRAG: dict = {
250 'begriff': 'Schießstätte',
251 'art': 'begriff',
252 'kurz': 'Anlage, in der geschossen wird und für deren Betrieb es einer Erlaubnis bedarf.',
253 'text': (
254 'Dieser Eintrag ist Prüfmaterial, kein Glossarinhalt: An ihm ist nichts zu '
255 'beanstanden, damit jeder Befund unten von der absichtlich eingebauten '
256 'Abweichung stammt und nicht vom Rest des Eintrags.'
257 ),
258 'varianten': ['Schießstätte'],
259 'fundstellen': [{'gesetz': 'StGB', 'norm': '§ 32', 'absatz': '1'}],
260 }
261
262 #: Ein zweiter gültiger Eintrag. Er verweist auf den ersten und belegt damit
263 #: die erlaubte Seite zweier Regeln, die sonst nur von ihrer Fehlerseite her
264 #: geprüft wären: ein `siehe` auf einen vorhandenen Eintrag und eine Lücke bei
265 #: den Fundstellen, die begründet ist. Ohne `text` – der ist zulässigerweise
266 #: freiwillig.
267 ZWEITER_EINTRAG: dict = {
268 'begriff': 'Schießstand',
269 'art': 'begriff',
270 'kurz': 'Einzelne Schießbahn innerhalb einer Schießstätte.',
271 'varianten': ['Schießstand'],
272 'siehe': ['Schießstätte'],
273 'fundstellen': [],
274 'ohneFundstelleGrund': 'Ein technischer Begriff ohne eigene gesetzliche Bestimmung.',
275 }
276
277 #: Eine gültige Glossardatei als Ausgangspunkt für die Fälle, die erst im
278 #: Zusammenhang der ganzen Datei entstehen können.
279 GUELTIGE_DATEI: dict = {
280 'meta': {
281 'version': 1,
282 'stand': '2026-01-01',
283 'hinweis': 'Prüfmaterial des Selbsttests, kein ausgelieferter Inhalt.',
284 'gesetzesstand': {},
285 },
286 'eintraege': [GUELTIGER_EINTRAG, ZWEITER_EINTRAG],
287 }
288
289 #: Bewusst fehlerhafte Einträge: Name, Abweichung vom gültigen Eintrag, Feld
290 #: und erwartete Meldung. Jeder Fall muss beanstandet werden.
291 #:
292 #: Anders als in `pruefe_erklaerungen.py` gehört das Feld zur Erwartung. Hier
293 #: teilen sich mehrere Regeln denselben Wortlaut – „fehlt oder ist keine Liste"
294 #: gilt für `varianten` wie für `fundstellen`, „zu kurz" für vier Felder. Eine
295 #: Erwartung nur auf den Meldungstext wäre schon erfüllt, wenn eine ganz andere
296 #: Regel angeschlagen hätte.
297 SELBSTTEST_EINTRAG: list[tuple[str, dict, str, str]] = [
298 # ── Form der Textfelder ──
299 ('Begriff fehlt', {'begriff': None}, 'begriff', 'fehlt oder ist leer'),
300 ('Begriff zu kurz', {'begriff': 'A'}, 'begriff', 'zu kurz'),
301 ('Begriff zu lang', {'begriff': 'Schießstätte' * 6}, 'begriff', 'zu lang'),
302 ('Kurztext besteht nur aus Leerraum', {'kurz': ' '}, 'kurz', 'fehlt oder ist leer'),
303 ('Kurztext zu kurz', {'kurz': 'Zu knapp.'}, 'kurz', 'zu kurz'),
304 ('Kurztext zu lang', {'kurz': 'x' * (MAX_KURZ + 1)}, 'kurz', 'zu lang'),
305 (
306 'Kurztext mit Leerraum am Rand',
307 {'kurz': ' Anlage, in der geschossen wird und die eine Erlaubnis braucht.'},
308 'kurz',
309 'beginnt oder endet mit Leerraum',
310 ),
311 ('Volltext zu kurz', {'text': 'Zu wenig Text.'}, 'text', 'zu kurz'),
312 ('Volltext zu lang', {'text': 'x' * (MAX_TEXT + 1)}, 'text', 'zu lang'),
313 # ── Art ──
314 ('unbekannte Art', {'art': 'fachwort'}, 'art', 'muss begriff oder abkuerzung sein'),
315 ('Art fehlt', {'art': None}, 'art', 'muss begriff oder abkuerzung sein'),
316 # ── Wortformen ──
317 (
318 'Wortformen sind keine Liste',
319 {'varianten': 'Schießstätte'},
320 'varianten',
321 'fehlt oder ist keine Liste',
322 ),
323 ('leere Wortform', {'varianten': ['']}, 'varianten', 'ungültige Wortform'),
324 (
325 'Wortform ist keine Zeichenkette',
326 {'varianten': [42]},
327 'varianten',
328 'ungültige Wortform',
329 ),
330 # ── Querverweise ──
331 (
332 'Verweis auf einen Eintrag, den es nicht gibt',
333 {'siehe': ['Handfeuerlöscher']},
334 'siehe',
335 'diesen Eintrag gibt es nicht',
336 ),
337 # ── Fundstellen und redliche Lücken ──
338 (
339 'Fundstellen sind keine Liste',
340 {'fundstellen': '§ 12 WaffG'},
341 'fundstellen',
342 'fehlt oder ist keine Liste',
343 ),
344 (
345 'Fundstelle ist kein Objekt',
346 {'fundstellen': ['§ 12 WaffG']},
347 'fundstelle[0]',
348 'ist kein Objekt',
349 ),
350 (
351 'Fundstelle fehlt ohne Begründung',
352 {'fundstellen': []},
353 'fundstellen',
354 'ohneFundstelleGrund',
355 ),
356 (
357 'Begründung der Lücke zu kurz',
358 {'fundstellen': [], 'ohneFundstelleGrund': 'Steht nirgends.'},
359 'ohneFundstelleGrund',
360 'zu kurz',
361 ),
362 (
363 'Begründung trotz vorhandener Fundstelle',
364 {'ohneFundstelleGrund': 'Zu diesem Begriff gibt es keine gesetzliche Bestimmung.'},
365 'ohneFundstelleGrund',
366 'obwohl Fundstellen vorhanden',
367 ),
368 # ── Unbekanntes Feld ──
369 ('unbekanntes Feld', {'quelle': 'irgendwoher'}, 'quelle', 'Unbekanntes Feld'),
370 # ── Die geerbte Fundstellenprüfung greift auch hier ──
371 #
372 # Nicht deren Regeln werden hier geprüft – das tut
373 # `pruefe_erklaerungen.py --selbsttest`, der seine Fallzahl selbst
374 # ausgibt –, sondern nur, dass ein Glossareintrag sie überhaupt erreicht.
375 # (Hier stand bis Fassung 0.24.1 „mit 22 Fällen“. Die Zahl war überholt,
376 # und sie stand ausgerechnet an einer Stelle, an der niemand nachrechnet;
377 # eine Zahl in Prosa läuft ohne Wache immer wieder auseinander.) Wäre der
378 # Aufruf in
379 # `eintrag_pruefen` verlorengegangen, bliebe jede erfundene Fundstelle im
380 # Glossar unbemerkt, während der Selbsttest der Erklärungen weiter grün
381 # meldete.
382 (
383 'erfundener Paragraf in einer Fundstelle',
384 {'fundstellen': [{'gesetz': 'WaffG', 'norm': '§ 999'}]},
385 'fundstelle[0]',
386 'gibt es im WaffG nicht',
387 ),
388 (
389 'Absatz als Zahl statt als Zeichenkette',
390 {'fundstellen': [{'gesetz': 'StGB', 'norm': '§ 32', 'absatz': 1}]},
391 'fundstelle[0]',
392 'muss eine Zeichenkette sein',
393 ),
394 ]
395
396 #: Fälle, die keinen Fehler darstellen, aber sichtbar werden müssen: Name,
397 #: Abweichung, Name der Hinweisliste, erwarteter Eintrag darin. Geprüft wird
398 #: beides – dass der Hinweis erscheint und dass er *keine* Beanstandung
399 #: auslöst. Ein Hinweis, der als Fehler zählt, hielte die Auslieferung an;
400 #: einer, der nirgends auftaucht, ist keiner.
401 SELBSTTEST_HINWEIS: list[tuple[str, dict, str, str]] = [
402 (
403 'Wortform, die nur als Wortanfang vorkommt',
404 {'varianten': ['Schießstätt']},
405 'ohne_vorkommen',
406 'Schießstätt',
407 ),
408 (
409 'Wortform, die im ganzen Text nicht vorkommt',
410 {'varianten': ['Handfeuerlöscher']},
411 'ohne_vorkommen',
412 'Handfeuerlöscher',
413 ),
414 (
415 'Eintrag ohne jede Wortform',
416 {'varianten': []},
417 'nur_im_glossar',
418 'Schießstätte',
419 ),
420 ]
421
422 #: Fehler, die erst im Zusammenhang der ganzen Datei entstehen.
423 SELBSTTEST_DATEI: list[tuple[str, dict, str, str]] = [
424 ('Kopf fehlt', {'meta': None}, 'meta', 'fehlt'),
425 (
426 'Gesetzesstand nennt ein unbekanntes Gesetz',
427 {'meta': {'gesetzesstand': {'BGB': 'Neugefasst durch Bek. v. 2.1.2002 I 42'}}},
428 'gesetzesstand',
429 'Unbekanntes Gesetz',
430 ),
431 (
432 'vermerkter Gesetzesstand weicht vom Index ab',
433 {'meta': {'gesetzesstand': {'WaffG': 'Stand von vorgestern'}}},
434 'gesetzesstand',
435 'Index sagt',
436 ),
437 ('Einträge fehlen', {'eintraege': None}, 'eintraege', 'fehlt oder ist keine Liste'),
438 (
439 'Eintrag ist kein Objekt',
440 {'eintraege': ['Schießstätte']},
441 'eintraege',
442 'Eintrag ist kein Objekt',
443 ),
444 (
445 'Begriff kommt zweimal vor',
446 {'eintraege': [GUELTIGER_EINTRAG, GUELTIGER_EINTRAG]},
447 'begriff',
448 'kommt mehrfach vor',
449 ),
450 ]
451
452
453 def melde_ergebnis(name: str, getroffen: bool, erwartet: str, gemeldet: str) -> int:
454 """Gibt einen Fall aus und antwortet mit 1, wenn er durchgelassen wurde."""
455 if getroffen:
456 print(f' erkannt: {name}')
457 return 0
458 print(f' DURCHGELASSEN: {name}')
459 print(f' erwartet: {erwartet}')
460 print(f' gemeldet: {gemeldet}')
461 return 1
462
463
464 def befunde_lesbar(pruefung: Glossarpruefung) -> str:
465 if not pruefung.befunde:
466 return '(nichts beanstandet)'
467 return '; '.join(f'{b.feld}: {b.meldung}' for b in pruefung.befunde)
468
469
470 def selbsttest(katalog: dict, gesetze: dict, korpus: str) -> int:
471 """Prüft die Prüfung.
472
473 Jede Regel wird einmal absichtlich verletzt – an echten Daten: Korpus,
474 Katalog und Gesetzesindex sind dieselben wie im Bestandslauf. Am Ende
475 steht die Gegenprobe, denn eine Prüfung, die alles beanstandet, wäre
476 genauso wertlos wie eine, die nichts beanstandet.
477 """
478 fehlgeschlagen = 0
479 gelaufen = 0
480 bekannt = {GUELTIGER_EINTRAG['begriff'], ZWEITER_EINTRAG['begriff']}
481
482 print('Einzelne Einträge:')
483 for name, aenderung, feld, erwartet in SELBSTTEST_EINTRAG:
484 pruefung = Glossarpruefung(katalog, gesetze)
485 pruefung.eintrag_pruefen({**GUELTIGER_EINTRAG, **aenderung}, bekannt, korpus)
486 getroffen = any(b.feld == feld and erwartet in b.meldung for b in pruefung.befunde)
487 fehlgeschlagen += melde_ergebnis(
488 name, getroffen, f'{feld}: {erwartet}', befunde_lesbar(pruefung)
489 )
490 gelaufen += 1
491
492 print('\nHinweise – kein Fehler, aber sie müssen sichtbar werden:')
493 for name, aenderung, liste, erwartet in SELBSTTEST_HINWEIS:
494 pruefung = Glossarpruefung(katalog, gesetze)
495 pruefung.eintrag_pruefen({**GUELTIGER_EINTRAG, **aenderung}, bekannt, korpus)
496 gesammelt: list[str] = getattr(pruefung, liste)
497 getroffen = any(erwartet in eintrag for eintrag in gesammelt) and not pruefung.befunde
498 gemeldet = ', '.join(gesammelt) or '(kein Hinweis)'
499 if pruefung.befunde:
500 gemeldet += f' – dazu beanstandet: {befunde_lesbar(pruefung)}'
501 fehlgeschlagen += melde_ergebnis(
502 name, getroffen, f'{liste}: {erwartet}, ohne Beanstandung', gemeldet
503 )
504 gelaufen += 1
505
506 print('\nDie Datei als Ganzes:')
507 for name, aenderung, feld, erwartet in SELBSTTEST_DATEI:
508 pruefung = Glossarpruefung(katalog, gesetze)
509 pruefung.alles_pruefen({**GUELTIGE_DATEI, **aenderung}, korpus)
510 getroffen = any(b.feld == feld and erwartet in b.meldung for b in pruefung.befunde)
511 fehlgeschlagen += melde_ergebnis(
512 name, getroffen, f'{feld}: {erwartet}', befunde_lesbar(pruefung)
513 )
514 gelaufen += 1
515
516 print('\nDie Gegenprobe:')
517
518 # Auch die Hinweislisten müssen leer bleiben. Wäre die Wortsuche kaputt,
519 # fände sie „Schießstätte" nicht mehr – und kein einziger Fall oben würde
520 # das bemerken, weil ein fehlender Treffer dort ja gerade erwartet wird.
521 pruefung = Glossarpruefung(katalog, gesetze)
522 pruefung.alles_pruefen(GUELTIGE_DATEI, korpus)
523 sauber = not pruefung.befunde and not pruefung.ohne_vorkommen and not pruefung.nur_im_glossar
524 gemeldet = befunde_lesbar(pruefung)
525 if pruefung.ohne_vorkommen:
526 gemeldet += f' – ohne Vorkommen: {", ".join(pruefung.ohne_vorkommen)}'
527 if pruefung.nur_im_glossar:
528 gemeldet += f' – ohne Wortform: {", ".join(pruefung.nur_im_glossar)}'
529 fehlgeschlagen += melde_ergebnis(
530 'gültige Einträge bleiben unbeanstandet und ohne Hinweis',
531 sauber,
532 'kein Befund, kein Hinweis',
533 gemeldet,
534 )
535 gelaufen += 1
536
537 # Und der ausgelieferte Bestand selbst: Regeln, die nur an erfundenen
538 # Beispielen greifen, aber die echte Datei anhalten würden, sind keine.
539 if GLOSSAR.exists():
540 daten = json.load(io.open(GLOSSAR, encoding='utf-8'))
541 pruefung = Glossarpruefung(katalog, gesetze)
542 pruefung.alles_pruefen(daten, korpus)
543 fehlgeschlagen += melde_ergebnis(
544 f'der ausgelieferte Bestand löst keinen Fehlalarm aus '
545 f'({len(daten.get("eintraege") or [])} Einträge)',
546 not pruefung.befunde,
547 'keine Beanstandung',
548 befunde_lesbar(pruefung),
549 )
550 gelaufen += 1
551 else:
552 # Kein Fehlschlag – ohne Glossar läuft auch die Anwendung. Aber es
553 # muss dastehen, sonst zählte der Lauf einen Fall mit, den er nicht
554 # gefahren hat.
555 print(f' ENTFÄLLT: kein Glossar unter {GLOSSAR.relative_to(WURZEL)}')
556
557 if fehlgeschlagen:
558 print(f'\nSelbsttest fehlgeschlagen: {fehlgeschlagen} von {gelaufen} Fällen.')
559 return 1
560
561 print(f'\nSelbsttest bestanden: {gelaufen} Fälle.')
562 return 0
563
564
565 def main(argv: list[str] | None = None) -> int:
566 argumente = list(sys.argv[1:] if argv is None else argv)
567 nur_selbsttest = '--selbsttest' in argumente
568 if nur_selbsttest:
569 argumente.remove('--selbsttest')
570
571 # Ein vertippter Schalter darf nicht stillschweigend die Bestandsprüfung
572 # fahren. Genau so blieb „--selbsttest" hier wirkungslos: Die Fahne stand
573 # im Modulkopf, main() las sys.argv nie, und der Aufruf meldete
574 # Rückgabewert 0 für eine Prüfung, die niemand angefordert hatte.
575 if argumente:
576 print(f'Unbekanntes Argument: {" ".join(argumente)}')
577 print('Aufruf: python data-pipeline/pruefe_glossar.py [--selbsttest]')
578 return 2
579
580 if nur_selbsttest:
581 katalog = json.load(io.open(KATALOG, encoding='utf-8'))
582 gesetze = json.load(io.open(GESETZE, encoding='utf-8'))
583 return selbsttest(katalog, gesetze, korpus_bilden())
584
585 if not GLOSSAR.exists():
586 print(f'Kein Glossar gefunden: {GLOSSAR.relative_to(WURZEL)}')
587 print('Das ist zulässig – die Anwendung läuft auch ohne.')
588 return 0
589
590 katalog = json.load(io.open(KATALOG, encoding='utf-8'))
591 gesetze = json.load(io.open(GESETZE, encoding='utf-8'))
592 daten = json.load(io.open(GLOSSAR, encoding='utf-8'))
593
594 pruefung = Glossarpruefung(katalog, gesetze)
595 pruefung.alles_pruefen(daten, korpus_bilden())
596
597 eintraege = daten.get('eintraege') or []
598 begriffe = [e for e in eintraege if isinstance(e, dict) and e.get('art') == 'begriff']
599 kuerzel = [e for e in eintraege if isinstance(e, dict) and e.get('art') == 'abkuerzung']
600
601 print(f'Einträge: {len(eintraege)}')
602 print(f'davon Begriffe: {len(begriffe)}')
603 print(f'davon Abkürzungen: {len(kuerzel)}')
604 print(f'Wortformen: {sum(len(e.get("varianten") or []) for e in eintraege)}')
605 # Dieselbe ehrliche Zählung wie in pruefe_erklaerungen.py: Der Sammelzähler
606 # dort wies auch Fundstellen als „geprüft" aus, von denen nur die Existenz
607 # der Norm nachgewiesen war. Das Glossar erbt die Prüfung – und die Zählung.
608 print(f'geprüfte Fundstellen: {pruefung.voll_geprueft + pruefung.nur_existenz}')
609 print(f' vollständig geprüft: {pruefung.voll_geprueft} (jede Angabe nachgewiesen)')
610 print(
611 f' nur Existenz geprüft: {pruefung.nur_existenz}'
612 ' (Feinangabe maschinell nicht prüfbar)'
613 )
614
615 if pruefung.nur_im_glossar:
616 print(f'\nNur in der Glossaransicht, an keiner Frage ({len(pruefung.nur_im_glossar)}):')
617 print(f' {", ".join(pruefung.nur_im_glossar)}')
618 print(' (Kein Fehler – diese Einträge nennen keine Wortform.)')
619
620 if pruefung.ohne_vorkommen:
621 print(f'\nWortformen ohne Vorkommen ({len(pruefung.ohne_vorkommen)}):')
622 for eintrag in pruefung.ohne_vorkommen[:25]:
623 print(f' {eintrag}')
624 if len(pruefung.ohne_vorkommen) > 25:
625 print(f' … und {len(pruefung.ohne_vorkommen) - 25} weitere')
626 print(' (Kein Fehler – aber solche Formen finden nie einen Treffer.)')
627
628 if pruefung.befunde:
629 print(f'\n{len(pruefung.befunde)} Beanstandungen:\n')
630 for befund in pruefung.befunde[:100]:
631 print(f' {befund}')
632 if len(pruefung.befunde) > 100:
633 print(f' … und {len(pruefung.befunde) - 100} weitere')
634 return 1
635
636 print('\nAlle Fundstellen im amtlichen Gesetzestext nachgewiesen.')
637 return 0
638
639
640 if __name__ == '__main__':
641 sys.stdout.reconfigure(encoding='utf-8', errors='replace') # type: ignore[union-attr]
642 raise SystemExit(main())