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