waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
/ data-pipeline pruefe_erklaerungen.py
| 1 | """Prüft die Erklärungstexte gegen Katalog und amtlichen Gesetzestext. |
| 2 | |
| 3 | Diese Prüfung entscheidet, ob Erklärungen ausgeliefert werden dürfen. Sie |
| 4 | bricht mit Rückgabewert 1 ab, sobald etwas nicht stimmt, und eignet sich |
| 5 | damit für eine Baupipeline. |
| 6 | |
| 7 | Was geprüft wird |
| 8 | ---------------- |
| 9 | 1. **Zuordnung** – jede Erklärung gehört zu einer Frage, die es im Katalog |
| 10 | wirklich gibt. |
| 11 | 2. **Fundstellen** – jedes zitierte Gesetz, jeder Paragraf, jeder Absatz, |
| 12 | jede Nummer und jeder Buchstabe wird im Index aus |
| 13 | `content/gesetze/index.json` nachgeschlagen. Was dort nicht steht, |
| 14 | existiert nicht. Bei Normen ohne Absatzgliederung (etwa § 37a WaffG) |
| 15 | stehen Nummern und Buchstaben nur im Normtext; geprüft wird dann gegen |
| 16 | dessen Aufzählungsmarken. Satzangaben sind im XML nicht ausgezeichnet |
| 17 | und werden als Plausibilitätsgrenze geprüft (mehr Sätze als der Text |
| 18 | hergibt, kann die Angabe nicht meinen). |
| 19 | 3. **Anlagen** – bei Anlagen greift die Absatzstruktur nicht. Geprüft wird, |
| 20 | ob die angegebene Stelle im Volltext der Anlage vorkommt. Ohne |
| 21 | Stellenangabe wäre nur die Existenz der Anlage nachgewiesen – das wird |
| 22 | beanstandet. |
| 23 | 4. **Redliche Lücken** – eine Erklärung ohne Fundstelle muss begründen, |
| 24 | warum es keine gibt. Damit ist das Fehlen eine bewusste Aussage und keine |
| 25 | Nachlässigkeit. |
| 26 | 5. **Form** – Länge, Sprache, keine leeren Felder, keine Platzhalter. |
| 27 | 6. **Typen** – jede Angabe einer Fundstelle ist eine Zeichenkette. Diese |
| 28 | Prüfung muss mindestens so streng sein wie der Lader in der Anwendung |
| 29 | (`app/src/main/erklaerungen.ts`). War sie es einmal nicht: 450 Angaben |
| 30 | standen als Zahl in der Datei, kamen hier durch und liessen die gepackte |
| 31 | Anwendung ohne eine einzige Erklärung starten. |
| 32 | |
| 33 | Was „geprüft" heißt |
| 34 | ------------------- |
| 35 | Existenz der Norm plus Nachweis jeder angegebenen Feinstelle (Absatz, Nummer, |
| 36 | Buchstabe, Anlagen-Stelle; Satz als Plausibilitätsgrenze). Die Ausgabe |
| 37 | unterscheidet „vollständig geprüft" (jede Angabe nachgewiesen) von „nur |
| 38 | Existenz geprüft" (mehr gab die Angabe maschinell nicht her). Ein einziger |
| 39 | Zähler hatte beides vermengt: Er zählte schon nach der Existenzprüfung, und |
| 40 | zwei frühe Rückkehrpfade ließen Nummern ohne Absatzangabe sowie fast alle |
| 41 | Satzangaben ungeprüft passieren – als „geprüft" ausgewiesen waren sie |
| 42 | trotzdem. |
| 43 | |
| 44 | Was diese Prüfung NICHT leisten kann |
| 45 | ------------------------------------ |
| 46 | Ob die zitierte Norm die Aussage inhaltlich trägt. Eine Erklärung kann auf |
| 47 | einen existierenden Paragrafen verweisen und trotzdem falsch sein. Dafür gibt |
| 48 | es die redaktionelle Durchsicht; diese Prüfung schließt nur die Fehlerklasse |
| 49 | aus, die sich maschinell sicher ausschließen lässt. |
| 50 | |
| 51 | Aufruf |
| 52 | ------ |
| 53 | python data-pipeline/pruefe_erklaerungen.py # Bestand prüfen |
| 54 | python data-pipeline/pruefe_erklaerungen.py --selbsttest # Prüfung prüfen |
| 55 | """ |
| 56 | |
| 57 | from __future__ import annotations |
| 58 | |
| 59 | import io |
| 60 | import json |
| 61 | import re |
| 62 | import sys |
| 63 | from dataclasses import dataclass |
| 64 | from pathlib import Path |
| 65 | |
| 66 | WURZEL = Path(__file__).resolve().parent.parent |
| 67 | KATALOG = WURZEL / 'content' / 'katalog' / 'katalog.json' |
| 68 | GESETZE = WURZEL / 'content' / 'gesetze' / 'index.json' |
| 69 | ERKLAERUNGEN = WURZEL / 'content' / 'erklaerungen.json' |
| 70 | |
| 71 | MIN_KURZ, MAX_KURZ = 30, 400 |
| 72 | |
| 73 | #: Laengengrenzen der ausfuehrlichen Begruendung. |
| 74 | #: |
| 75 | #: Die Obergrenze fangt Wildwuchs ab, nicht Gruendlichkeit. Gemessen an den |
| 76 | #: ersten 193 Erklaerungen: Median 1194 Zeichen, 90 Prozent unter 1634. Ueber |
| 77 | #: 2500 lag genau eine - die Frage nach der Beschusspflicht, die vier |
| 78 | #: Waffenarten einzeln durchgeht und fuer jede eine eigene Ausnahme nennt. |
| 79 | #: Solche Faelle gibt es, und sie zu verstuemmeln waere der schlechtere Weg. |
| 80 | MIN_TEXT, MAX_TEXT = 80, 3000 |
| 81 | |
| 82 | MAX_MERKSATZ = 300 |
| 83 | MIN_GRUND = 25 |
| 84 | |
| 85 | #: Wörter, die auf einen unfertigen Text hindeuten. |
| 86 | PLATZHALTER = re.compile( |
| 87 | r'\b(TODO|TBD|FIXME|Platzhalter|Lorem ipsum|XXX|\.\.\.\s*$)', re.IGNORECASE |
| 88 | ) |
| 89 | |
| 90 | #: Hoechstzahl der Kernpunkte. Mehr waere keine Pruefliste mehr, sondern eine |
| 91 | #: zweite Musterantwort. |
| 92 | MAX_KERNPUNKTE = 8 |
| 93 | |
| 94 | #: Ein Kernpunkt ist ein Stichwort, kein Satzgefuege. |
| 95 | MAX_KERNPUNKT_LAENGE = 140 |
| 96 | |
| 97 | #: Die eine Formulierung, die hier nie stehen darf. Bis Fassung 0.17.0 stand |
| 98 | #: ueber einer aehnlichen Liste "Diese Kernelemente muss Ihre Antwort |
| 99 | #: enthalten" - unbelegt, und ausdruecklich abgeschafft. Die Pruefung haelt |
| 100 | #: fest, dass sie nicht durch die Hintertuer zurueckkommt. |
| 101 | PFLICHTBEHAUPTUNG = re.compile(r'muss\s+(ihre|die)\s+antwort|zwingend\s+erforderlich', re.I) |
| 102 | |
| 103 | |
| 104 | #: Erlaubte Formen einer Normbezeichnung. |
| 105 | #: |
| 106 | #: Anlagen werden nicht einheitlich bezeichnet: WaffG und 1. SprengV zaehlen |
| 107 | #: arabisch ("Anlage 1"), BeschussV und SprengG roemisch ("Anlage II"), die |
| 108 | #: AWaffV hat genau eine und nennt sie nur "Anlage". |
| 109 | NORM_MUSTER = re.compile(r'^(§ \d+[a-z]?|Anlage( [0-9IVX]+)?)$') |
| 110 | |
| 111 | #: Eine Nummer ist eine Zahl, ggf. mit Buchstabenzusatz („3", „3a"). |
| 112 | NUMMER_MUSTER = re.compile(r'^\d+[a-z]?$') |
| 113 | |
| 114 | #: Gliederungsmarken innerhalb einer Anlage: „Abschnitt 1", „Nr. 1.1", |
| 115 | #: „Abbildung 12". Alles Weitere in einer Stellenangabe ist Beschreibung und |
| 116 | #: wird nicht geprueft - sie stammt vom Verfasser, nicht aus dem Gesetz. |
| 117 | GLIEDERUNGSMARKE = re.compile( |
| 118 | r'(Abschnitt|Unterabschnitt|Abbildung|Anhang|Kategorie|Tabelle|Teil|Nummer|Nrn?\.?)' |
| 119 | r'\s+([0-9IVX][0-9IVXa-z.]*?)(?=[\s,;:)]|$)', |
| 120 | re.IGNORECASE, |
| 121 | ) |
| 122 | |
| 123 | #: Aufzählungsmarken am Zeilenanfang eines Normtexts: „1." bzw. „a)". Der |
| 124 | #: Punkt darf keine Ziffer nach sich haben, sonst träfe „1." auch „1.1". |
| 125 | NORMTEXT_NUMMER = re.compile(r'^(\d+[a-z]?)\.(?!\d)') |
| 126 | NORMTEXT_BUCHSTABE = re.compile(r'^([a-z]{1,2})\)') |
| 127 | |
| 128 | |
| 129 | def norm_gliederung(text: str) -> dict[str, list[str]]: |
| 130 | """Liest die Aufzählungsstruktur aus dem Text einer ungegliederten Norm. |
| 131 | |
| 132 | Der Index legt Gliederung nur je Absatz ab. Beginnt eine Norm ohne |
| 133 | „(1)"-Marke, hat sie im Index keine Absätze; 251 Paragrafen des Index sind |
| 134 | das. Zitiert werden davon mit Nummer oder Buchstabe genau vier: § 8 und |
| 135 | § 37a WaffG, § 29 SprengG, § 34 AWaffV. Ihre Nummern und Buchstaben stehen |
| 136 | zeilenweise im Normtext, denn der Indexer beginnt für jede `<DT>`-Marke |
| 137 | eine Zeile. |
| 138 | |
| 139 | Buchstaben gehören zur zuletzt begonnenen Nummer; steht keine voran, zählt |
| 140 | die Norm unmittelbar mit Buchstaben auf (dieselbe Zuordnung wie falten() |
| 141 | in data-pipeline/gesetze_index.py). |
| 142 | """ |
| 143 | gliederung: dict[str, list[str]] = {} |
| 144 | letzte_nummer: str | None = None |
| 145 | |
| 146 | for zeile in text.split('\n'): |
| 147 | treffer = NORMTEXT_NUMMER.match(zeile) |
| 148 | if treffer: |
| 149 | letzte_nummer = treffer.group(1) |
| 150 | gliederung.setdefault(letzte_nummer, []) |
| 151 | continue |
| 152 | treffer = NORMTEXT_BUCHSTABE.match(zeile) |
| 153 | if treffer: |
| 154 | if letzte_nummer is None: |
| 155 | gliederung.setdefault(treffer.group(1), []) |
| 156 | else: |
| 157 | gliederung[letzte_nummer].append(treffer.group(1)) |
| 158 | |
| 159 | return gliederung |
| 160 | |
| 161 | |
| 162 | @dataclass |
| 163 | class Befund: |
| 164 | frage_id: str |
| 165 | feld: str |
| 166 | meldung: str |
| 167 | |
| 168 | def __str__(self) -> str: |
| 169 | return f'{self.frage_id:12} {self.feld:16} {self.meldung}' |
| 170 | |
| 171 | |
| 172 | class Pruefung: |
| 173 | def __init__(self, katalog: dict, gesetze: dict) -> None: |
| 174 | self.fragen = {f['id']: f for f in katalog['fragen']} |
| 175 | self.gesetze = gesetze['gesetze'] |
| 176 | self.befunde: list[Befund] = [] |
| 177 | #: Stellenangaben ohne erkennbare Gliederungsmarke. Kein Fehler, aber |
| 178 | #: maschinell nicht nachprüfbar – das soll sichtbar bleiben. |
| 179 | self.ungeprueft: list[str] = [] |
| 180 | #: Zählt, wie oft eine Norm zitiert wird – als Anhaltspunkt für die |
| 181 | #: redaktionelle Durchsicht, nicht als Prüfkriterium. |
| 182 | self.normnutzung: dict[str, int] = {} |
| 183 | #: Ehrliche Zählung statt eines Sammelzählers: „voll_geprueft" heißt, |
| 184 | #: jede Angabe der Fundstelle wurde gegen den Gesetzestext gehalten; |
| 185 | #: „nur_existenz" heißt, mehr als das Vorhandensein der Norm gab die |
| 186 | #: Angabe maschinell nicht her. Der frühere Sammelzähler wies beides |
| 187 | #: als „geprüft" aus – 188 der 1843 Fundstellen (Satzangaben, |
| 188 | #: Nummern ohne Absatz, eine Anlage ohne Stelle) waren es nur halb. |
| 189 | self.voll_geprueft = 0 |
| 190 | self.nur_existenz = 0 |
| 191 | #: Querverweise je Frage, fuer den Abgleich auf Beidseitigkeit. |
| 192 | self.verweise: dict[str, set[str]] = {} |
| 193 | |
| 194 | def melden(self, frage_id: str, feld: str, meldung: str) -> None: |
| 195 | self.befunde.append(Befund(frage_id, feld, meldung)) |
| 196 | |
| 197 | # ── Fundstellen ────────────────────────────────────────────────────── |
| 198 | |
| 199 | def fundstelle_pruefen(self, frage_id: str, index: int, fundstelle: dict) -> None: |
| 200 | ort = f'fundstelle[{index}]' |
| 201 | |
| 202 | # Der Lader in der Anwendung prueft die Typen. Ist diese Pruefung hier |
| 203 | # laxer, geht etwas durch, das erst beim Nutzer scheitert - genau das |
| 204 | # ist passiert: 450 Angaben standen als Zahl statt als Zeichenkette in |
| 205 | # der Datei, kamen durch die Pruefung und liessen die gepackte |
| 206 | # Anwendung ohne eine einzige Erklaerung starten. |
| 207 | for feld in ('gesetz', 'norm', 'absatz', 'nummer', 'buchstabe', 'satz', 'stelle'): |
| 208 | wert = fundstelle.get(feld) |
| 209 | if wert is not None and not isinstance(wert, str): |
| 210 | self.melden( |
| 211 | frage_id, |
| 212 | ort, |
| 213 | f'„{feld}" muss eine Zeichenkette sein, ist {type(wert).__name__}: {wert!r}', |
| 214 | ) |
| 215 | return |
| 216 | |
| 217 | kuerzel = fundstelle.get('gesetz') |
| 218 | if kuerzel not in self.gesetze: |
| 219 | self.melden(frage_id, ort, f'Unbekanntes Gesetz: {kuerzel!r}') |
| 220 | return |
| 221 | |
| 222 | gesetz = self.gesetze[kuerzel] |
| 223 | norm_name = fundstelle.get('norm', '') |
| 224 | |
| 225 | if not NORM_MUSTER.match(str(norm_name)): |
| 226 | self.melden( |
| 227 | frage_id, ort, f'Norm muss „§ 12" oder „Anlage 1" lauten, ist: {norm_name!r}' |
| 228 | ) |
| 229 | return |
| 230 | |
| 231 | norm = gesetz['normen'].get(norm_name) |
| 232 | if norm is None: |
| 233 | self.melden(frage_id, ort, f'{norm_name} gibt es im {kuerzel} nicht') |
| 234 | return |
| 235 | |
| 236 | self.normnutzung[f'{kuerzel} {norm_name}'] = ( |
| 237 | self.normnutzung.get(f'{kuerzel} {norm_name}', 0) + 1 |
| 238 | ) |
| 239 | |
| 240 | # Ab hier ist die Existenz der Norm nachgewiesen. Ob auch jede |
| 241 | # Feinangabe nachgeprüft werden konnte, entscheiden die beiden Zweige – |
| 242 | # der Zähler übernimmt deren Antwort, statt vorab „geprüft" zu melden. |
| 243 | if norm['ist_anlage']: |
| 244 | vollstaendig = self.anlage_pruefen(frage_id, ort, kuerzel, norm_name, norm, fundstelle) |
| 245 | else: |
| 246 | vollstaendig = self.paragraf_pruefen( |
| 247 | frage_id, ort, kuerzel, norm_name, norm, fundstelle |
| 248 | ) |
| 249 | |
| 250 | if vollstaendig: |
| 251 | self.voll_geprueft += 1 |
| 252 | else: |
| 253 | self.nur_existenz += 1 |
| 254 | |
| 255 | def paragraf_pruefen( |
| 256 | self, |
| 257 | frage_id: str, |
| 258 | ort: str, |
| 259 | kuerzel: str, |
| 260 | norm_name: str, |
| 261 | norm: dict, |
| 262 | fundstelle: dict, |
| 263 | ) -> bool: |
| 264 | """Prüft die Feinstellen eines Paragrafenzitats. |
| 265 | |
| 266 | True heißt: Jede Angabe der Fundstelle wurde gegen den Gesetzestext |
| 267 | gehalten (Beanstandungen eingeschlossen). False heißt: Über die |
| 268 | Existenz der Norm hinaus war nichts nachprüfbar. |
| 269 | """ |
| 270 | if fundstelle.get('stelle') is not None: |
| 271 | self.melden(frage_id, ort, '„stelle" ist nur bei Anlagen zulässig') |
| 272 | |
| 273 | absatz_name = fundstelle.get('absatz') |
| 274 | if absatz_name is None: |
| 275 | # Zulässig: Ein Paragraf ohne Absatzgliederung wird als Ganzes |
| 276 | # zitiert. Bei gegliederten Normen ist das aber ungenau – und |
| 277 | # eine Nummer ließe sich keinem Absatz zuordnen. |
| 278 | if len(norm['absaetze']) > 1: |
| 279 | self.melden( |
| 280 | frage_id, |
| 281 | ort, |
| 282 | f'{norm_name} {kuerzel} hat {len(norm["absaetze"])} Absaetze – ' |
| 283 | 'bitte den zutreffenden angeben', |
| 284 | ) |
| 285 | return False |
| 286 | if norm['absaetze']: |
| 287 | # Genau ein Absatz: Jede Feinangabe kann sich nur auf ihn |
| 288 | # beziehen, also wird gegen ihn geprüft. |
| 289 | absatz = next(iter(norm['absaetze'].values())) |
| 290 | else: |
| 291 | # Norm ohne Absatzgliederung: Nummern und Buchstaben stehen |
| 292 | # nur im Normtext. Vorher kehrte die Prüfung hier um; die |
| 293 | # zwölf Feinangaben ohne Absatz blieben ungeprüft, davon acht |
| 294 | # Nummernangaben zu § 37a WaffG. |
| 295 | absatz = {'gliederung': norm_gliederung(norm['text']), 'text': norm['text']} |
| 296 | self.gliederung_pruefen(frage_id, ort, kuerzel, norm_name, None, absatz, fundstelle) |
| 297 | return True |
| 298 | |
| 299 | absatz = norm['absaetze'].get(str(absatz_name)) |
| 300 | if absatz is None: |
| 301 | vorhanden = ', '.join(norm['absaetze'].keys()) or 'keine' |
| 302 | self.melden( |
| 303 | frage_id, |
| 304 | ort, |
| 305 | f'{norm_name} {kuerzel} hat keinen Abs. {absatz_name} (vorhanden: {vorhanden})', |
| 306 | ) |
| 307 | return False |
| 308 | |
| 309 | self.gliederung_pruefen(frage_id, ort, kuerzel, norm_name, absatz_name, absatz, fundstelle) |
| 310 | return True |
| 311 | |
| 312 | def gliederung_pruefen( |
| 313 | self, |
| 314 | frage_id: str, |
| 315 | ort: str, |
| 316 | kuerzel: str, |
| 317 | norm_name: str, |
| 318 | absatz_name: str | None, |
| 319 | absatz: dict, |
| 320 | fundstelle: dict, |
| 321 | ) -> None: |
| 322 | if absatz_name is None: |
| 323 | # Ohne Absatzangabe zitiert – die Norm selbst ist die Stelle. |
| 324 | stelle = f'{norm_name} {kuerzel}' |
| 325 | else: |
| 326 | stelle = f'{norm_name} Abs. {absatz_name} {kuerzel}' |
| 327 | gliederung: dict[str, list[str]] = absatz['gliederung'] |
| 328 | |
| 329 | # Der Satz zuerst: Er ist von Nummer und Buchstabe unabhängig. Als er |
| 330 | # hinter deren Frührückkehren stand, wurde er nur erreicht, wenn |
| 331 | # Nummer UND Buchstabe angegeben waren – 183 von 184 Satzangaben |
| 332 | # gingen ungeprüft durch. |
| 333 | satz = fundstelle.get('satz') |
| 334 | if satz is not None: |
| 335 | # Sätze werden im XML nicht ausgezeichnet. Geprüft wird deshalb |
| 336 | # nur, dass der Text überhaupt so viele Sätze haben kann. |
| 337 | saetze = absatz['text'].count('.') + absatz['text'].count(';') |
| 338 | if not str(satz).isdigit() or int(satz) < 1: |
| 339 | self.melden(frage_id, ort, f'Satz muss eine Zahl ab 1 sein, ist: {satz!r}') |
| 340 | elif int(satz) > max(1, saetze): |
| 341 | self.melden(frage_id, ort, f'{stelle} hat vermutlich keinen Satz {satz}') |
| 342 | |
| 343 | nummer = fundstelle.get('nummer') |
| 344 | if nummer is not None: |
| 345 | # Ein blosser Buchstabe ist keine Nummer. Ohne diese Regel wuerde |
| 346 | # eine Fundstelle „Nr. a" durchgehen, sobald der Absatz |
| 347 | # unmittelbar mit Buchstaben aufzaehlt. |
| 348 | if not NUMMER_MUSTER.match(str(nummer)): |
| 349 | self.melden( |
| 350 | frage_id, |
| 351 | ort, |
| 352 | f'„nummer" muss eine Zahl sein, ist: {nummer!r} – ' |
| 353 | 'Buchstaben gehoeren nach „buchstabe"', |
| 354 | ) |
| 355 | return |
| 356 | if not gliederung: |
| 357 | self.melden(frage_id, ort, f'{stelle} ist nicht in Nummern gegliedert') |
| 358 | return |
| 359 | if str(nummer) not in gliederung: |
| 360 | vorhanden = ', '.join(gliederung.keys()) |
| 361 | self.melden( |
| 362 | frage_id, ort, f'{stelle} hat keine Nr. {nummer} (vorhanden: {vorhanden})' |
| 363 | ) |
| 364 | return |
| 365 | |
| 366 | buchstabe = fundstelle.get('buchstabe') |
| 367 | if buchstabe is None: |
| 368 | return |
| 369 | |
| 370 | if nummer is None: |
| 371 | # Zulaessig, wenn der Absatz selbst mit Buchstaben aufzaehlt – |
| 372 | # etwa § 3a Abs. 2 SprengG. |
| 373 | if str(buchstabe) in gliederung: |
| 374 | return |
| 375 | self.melden( |
| 376 | frage_id, |
| 377 | ort, |
| 378 | f'{stelle} zaehlt nicht unmittelbar mit Buchstaben auf – ' |
| 379 | 'bitte zusaetzlich die Nummer angeben', |
| 380 | ) |
| 381 | return |
| 382 | |
| 383 | buchstaben = gliederung.get(str(nummer), []) |
| 384 | if not buchstaben: |
| 385 | self.melden( |
| 386 | frage_id, ort, f'{stelle} Nr. {nummer} ist nicht in Buchstaben gegliedert' |
| 387 | ) |
| 388 | return |
| 389 | if str(buchstabe) not in buchstaben: |
| 390 | self.melden( |
| 391 | frage_id, |
| 392 | ort, |
| 393 | f'{stelle} Nr. {nummer} hat keinen Buchst. {buchstabe} ' |
| 394 | f'(vorhanden: {", ".join(buchstaben)})', |
| 395 | ) |
| 396 | |
| 397 | def anlage_pruefen( |
| 398 | self, |
| 399 | frage_id: str, |
| 400 | ort: str, |
| 401 | kuerzel: str, |
| 402 | norm_name: str, |
| 403 | norm: dict, |
| 404 | fundstelle: dict, |
| 405 | ) -> bool: |
| 406 | """Prüft die Stellenangabe eines Anlagenzitats. |
| 407 | |
| 408 | True heißt: Die Gliederungsmarken der Stelle wurden im Anlagentext |
| 409 | nachgewiesen (Beanstandungen eingeschlossen). False heißt: Über die |
| 410 | Existenz der Anlage hinaus war nichts nachprüfbar. |
| 411 | """ |
| 412 | # „satz" steht mit in der Liste: Anlagen kennen keine Satzzählung, |
| 413 | # und eine stumm ignorierte Angabe sähe wie eine geprüfte aus. |
| 414 | for verboten in ('absatz', 'nummer', 'buchstabe', 'satz'): |
| 415 | if fundstelle.get(verboten) is not None: |
| 416 | self.melden( |
| 417 | frage_id, |
| 418 | ort, |
| 419 | f'„{verboten}" ist bei {norm_name} nicht zulässig – bitte „stelle" verwenden', |
| 420 | ) |
| 421 | |
| 422 | stelle = fundstelle.get('stelle') |
| 423 | if stelle is None: |
| 424 | # Anlagen sind seitenlang; ohne Stellenangabe wäre nur die |
| 425 | # Existenz der Anlage nachgewiesen und der Leser suchte allein. |
| 426 | self.melden( |
| 427 | frage_id, |
| 428 | ort, |
| 429 | f'{norm_name} {kuerzel} ohne „stelle" – so ist nur die Existenz ' |
| 430 | 'der Anlage nachgewiesen, bitte die Stelle angeben', |
| 431 | ) |
| 432 | return False |
| 433 | |
| 434 | # Zeilenumbrüche stören den Vergleich; die Anlagen sind zeilenweise |
| 435 | # gesetzt, eine Angabe wie „Abbildung 1" aber steht als Wortfolge da. |
| 436 | text = ' '.join(norm['text'].split()) |
| 437 | marken = GLIEDERUNGSMARKE.findall(str(stelle)) |
| 438 | |
| 439 | if not marken: |
| 440 | # Keine Gliederungsmarke erkennbar – dann ist die Angabe reine |
| 441 | # Beschreibung und maschinell nicht prüfbar. Das ist kein Fehler, |
| 442 | # aber es soll sichtbar bleiben. |
| 443 | self.ungeprueft.append(f'{frage_id} {ort}: „{stelle}"') |
| 444 | return False |
| 445 | |
| 446 | for wort, zahl in marken: |
| 447 | if wort.lower().startswith(('nr', 'nummer')): |
| 448 | # Anlagen listen ihre Nummern ohne das Wort „Nr.". |
| 449 | # Listennummern stehen als „12." – der Punkt gehoert zur Marke. |
| 450 | # „1" darf aber nicht in „1.1" treffen: deshalb nicht vor einer |
| 451 | # Ziffer, auch nicht ueber einen Punkt hinweg. |
| 452 | gefunden = ( |
| 453 | re.search(rf'(?<![\d.]){re.escape(zahl)}(?!\.?\d)', text) is not None |
| 454 | ) |
| 455 | else: |
| 456 | gefunden = re.search( |
| 457 | rf'{re.escape(wort)}\s*{re.escape(zahl)}(?![\d.])', text, re.IGNORECASE |
| 458 | ) is not None |
| 459 | |
| 460 | if not gefunden: |
| 461 | self.melden( |
| 462 | frage_id, ort, f'„{wort} {zahl}" kommt in {norm_name} {kuerzel} nicht vor' |
| 463 | ) |
| 464 | |
| 465 | return True |
| 466 | |
| 467 | # ── Erklärung als Ganzes ───────────────────────────────────────────── |
| 468 | |
| 469 | def text_pruefen(self, frage_id: str, feld: str, wert: object, unten: int, oben: int) -> bool: |
| 470 | if not isinstance(wert, str) or not wert.strip(): |
| 471 | self.melden(frage_id, feld, 'fehlt oder ist leer') |
| 472 | return False |
| 473 | text = wert.strip() |
| 474 | if len(text) < unten: |
| 475 | self.melden(frage_id, feld, f'zu kurz ({len(text)} Zeichen, mindestens {unten})') |
| 476 | return False |
| 477 | if len(text) > oben: |
| 478 | self.melden(frage_id, feld, f'zu lang ({len(text)} Zeichen, höchstens {oben})') |
| 479 | return False |
| 480 | if PLATZHALTER.search(text): |
| 481 | self.melden(frage_id, feld, 'enthält einen Platzhalter') |
| 482 | return False |
| 483 | if text != wert: |
| 484 | self.melden(frage_id, feld, 'beginnt oder endet mit Leerraum') |
| 485 | return False |
| 486 | return True |
| 487 | |
| 488 | # ── Antwortmöglichkeiten ───────────────────────────────────────────── |
| 489 | |
| 490 | #: Wörter, die für sich genommen eine Antwortmöglichkeit sind: „Nein", |
| 491 | #: „keine", „Ja, wenn …". Sie tragen keinen eigenen Sachbegriff, an dem |
| 492 | #: sich nachweisen ließe, dass die Erklärung sie behandelt. |
| 493 | #: |
| 494 | #: Eine Möglichkeit, die aus nichts anderem besteht – das blanke „Nein" |
| 495 | #: neben zwei Sachantworten –, ist deshalb immer als behandelt zu werten: |
| 496 | #: Sie ist die Verneinung der ganzen Frage, und die Erklärung beantwortet |
| 497 | #: sie, indem sie sagt, was gilt. Ein eigener Satz „Antwort c ist falsch" |
| 498 | #: brächte dort nichts, was nicht schon dastünde. |
| 499 | ANTWORTWOERTER = ('nein', 'ja', 'keine', 'keiner', 'keines', 'alle', 'doch', 'nichts') |
| 500 | |
| 501 | #: Zu kurz oder zu allgemein, um eine Option von ihren Geschwistern zu |
| 502 | #: unterscheiden. Ohne diese Liste zählte „Schusswaffe" als Nachweis, |
| 503 | #: obwohl es in jeder Option derselben Frage steht. |
| 504 | OHNE_UNTERSCHEIDUNGSKRAFT = frozenset( |
| 505 | '''schusswaffe schusswaffen waffe waffen munition person personen jahren jahre |
| 506 | gemaess waffenrechtlich waffenrechtliche waffenrechtlichen sinne gegenstand |
| 507 | gegenstaende moeglich moeglichkeit erlaubt verboten zulaessig gestattet'''.split() |
| 508 | ) |
| 509 | |
| 510 | @staticmethod |
| 511 | def _falte(text: str) -> str: |
| 512 | """Kleinschreibung ohne Umlaute – damit „Armbrust" auf „Armbrüste" trifft.""" |
| 513 | tief = text.lower() |
| 514 | for von, nach in (('ä', 'ae'), ('ö', 'oe'), ('ü', 'ue'), ('ß', 'ss')): |
| 515 | tief = tief.replace(von, nach) |
| 516 | return tief |
| 517 | |
| 518 | @staticmethod |
| 519 | def _buchstabenbezug(text: str) -> set: |
| 520 | """Welche Optionsbuchstaben nennt die Erklärung ausdrücklich? |
| 521 | |
| 522 | Der Hausstil kennt mehrere Formen, und genau daran ist die |
| 523 | ursprüngliche Messung gescheitert: Wer nur nach „a)" sucht, hält |
| 524 | „das macht Antwort b falsch" für keine Nennung. |
| 525 | """ |
| 526 | gefunden = set() |
| 527 | for muster in ( |
| 528 | r'(?:^|[^A-Za-zÄÖÜäöü])([a-f])\)', |
| 529 | r'„([a-f])“', |
| 530 | r'\bAntwort(?:möglichkeit)?\s+([a-f])\b', |
| 531 | r'\bZu\s+([a-f])\b', |
| 532 | r'\bBuchstabe\s+([a-f])\b', |
| 533 | ): |
| 534 | gefunden |= set(re.findall(muster, text)) |
| 535 | return gefunden |
| 536 | |
| 537 | def optionen_pruefen(self, frage_id: str, erklaerung: dict) -> None: |
| 538 | """Behandelt die Erklärung jede Antwortmöglichkeit? |
| 539 | |
| 540 | **Warum das hier steht.** Eine Erklärung, die eine Antwortmöglichkeit |
| 541 | übergeht, lässt den Lernenden mit der Frage allein, die ihn am |
| 542 | meisten beschäftigt: Warum ist gerade die, die ich angekreuzt habe, |
| 543 | falsch? Am 01.09.2026 traf das auf **keine einzige** der 471 |
| 544 | Auswahlfragen zu – erst durch diese Prüfung bleibt das so. |
| 545 | |
| 546 | **Warum die Prüfung so großzügig ist.** Der Hausstil benennt |
| 547 | Antwortmöglichkeiten auf zwei Wegen: mit ihrem Buchstaben (130 von |
| 548 | 471) oder mit ihrem Gegenstand – „die Armbrust scheitert am Lauf". |
| 549 | Der zweite Weg ist der bessere: Wer sich die Erklärung vorlesen |
| 550 | lässt, hört bei „zu a)" nichts, woran er sich erinnern könnte. Diese |
| 551 | Prüfung erkennt deshalb beide und fällt nur dann aus, wenn eine |
| 552 | Möglichkeit auf **keinem** der beiden Wege vorkommt. |
| 553 | """ |
| 554 | frage = self.fragen.get(frage_id) or {} |
| 555 | optionen = frage.get('optionen') or [] |
| 556 | if not optionen: |
| 557 | return |
| 558 | |
| 559 | text = f"{erklaerung.get('kurz') or ''} {erklaerung.get('text') or ''}" |
| 560 | gefaltet = self._falte(text) |
| 561 | briefe = self._buchstabenbezug(text) |
| 562 | kurz_anfang = self._falte((erklaerung.get('kurz') or '')[:40]) |
| 563 | |
| 564 | # Wörter, die in allen Möglichkeiten vorkommen, unterscheiden nichts. |
| 565 | woerter_je_option = [] |
| 566 | for option in optionen: |
| 567 | roh = (option.get('inhalt') or {}).get('text') or '' |
| 568 | woerter_je_option.append( |
| 569 | {w for w in re.findall(r'[a-z]{4,}', self._falte(roh))} |
| 570 | - self.OHNE_UNTERSCHEIDUNGSKRAFT |
| 571 | - set(self.ANTWORTWOERTER) |
| 572 | ) |
| 573 | gemeinsam = set.intersection(*woerter_je_option) if len(woerter_je_option) > 1 else set() |
| 574 | |
| 575 | for option, woerter in zip(optionen, woerter_je_option): |
| 576 | marke = option.get('label') or '?' |
| 577 | if marke in briefe: |
| 578 | continue |
| 579 | |
| 580 | roh = (option.get('inhalt') or {}).get('text') or '' |
| 581 | wortmenge = set(re.findall(r'[a-z]+', self._falte(roh))) |
| 582 | if any(w in self.ANTWORTWOERTER and w in kurz_anfang for w in wortmenge): |
| 583 | continue |
| 584 | |
| 585 | eigen = woerter - gemeinsam |
| 586 | if not eigen: |
| 587 | continue |
| 588 | # Wortstamm statt Wortgleichheit: „Leuchtspurpatronen" gilt als |
| 589 | # behandelt, wenn irgendwo „Leuchtspurmunition" steht. |
| 590 | if any(wort[: max(4, min(5, len(wort)))] in gefaltet for wort in eigen): |
| 591 | continue |
| 592 | |
| 593 | self.melden( |
| 594 | frage_id, |
| 595 | f'option[{marke}]', |
| 596 | 'kommt in der Erklärung weder mit ihrem Buchstaben noch mit ihrem ' |
| 597 | f'Gegenstand vor: „{roh[:70]}"', |
| 598 | ) |
| 599 | |
| 600 | def erklaerung_pruefen(self, frage_id: str, erklaerung: dict) -> None: |
| 601 | if frage_id not in self.fragen: |
| 602 | self.melden(frage_id, 'zuordnung', 'Diese Frage gibt es im Katalog nicht') |
| 603 | return |
| 604 | |
| 605 | erlaubt = { |
| 606 | 'kurz', |
| 607 | 'text', |
| 608 | 'fundstellen', |
| 609 | 'ohneFundstelleGrund', |
| 610 | 'merksatz', |
| 611 | 'verwandt', |
| 612 | 'kernpunkte', |
| 613 | } |
| 614 | for feld in set(erklaerung) - erlaubt: |
| 615 | self.melden(frage_id, feld, 'Unbekanntes Feld') |
| 616 | |
| 617 | self.optionen_pruefen(frage_id, erklaerung) |
| 618 | self.verwandt_pruefen(frage_id, erklaerung.get('verwandt')) |
| 619 | self.kernpunkte_pruefen(frage_id, erklaerung.get('kernpunkte')) |
| 620 | |
| 621 | self.text_pruefen(frage_id, 'kurz', erklaerung.get('kurz'), MIN_KURZ, MAX_KURZ) |
| 622 | self.text_pruefen(frage_id, 'text', erklaerung.get('text'), MIN_TEXT, MAX_TEXT) |
| 623 | |
| 624 | if 'merksatz' in erklaerung: |
| 625 | self.text_pruefen(frage_id, 'merksatz', erklaerung['merksatz'], 10, MAX_MERKSATZ) |
| 626 | |
| 627 | fundstellen = erklaerung.get('fundstellen') |
| 628 | if not isinstance(fundstellen, list): |
| 629 | self.melden(frage_id, 'fundstellen', 'fehlt oder ist keine Liste') |
| 630 | return |
| 631 | |
| 632 | for index, fundstelle in enumerate(fundstellen): |
| 633 | if not isinstance(fundstelle, dict): |
| 634 | self.melden(frage_id, f'fundstelle[{index}]', 'ist kein Objekt') |
| 635 | continue |
| 636 | self.fundstelle_pruefen(frage_id, index, fundstelle) |
| 637 | |
| 638 | grund = erklaerung.get('ohneFundstelleGrund') |
| 639 | if not fundstellen: |
| 640 | if grund is None: |
| 641 | self.melden( |
| 642 | frage_id, |
| 643 | 'fundstellen', |
| 644 | 'Ohne Fundstelle muss „ohneFundstelleGrund" gesetzt sein', |
| 645 | ) |
| 646 | else: |
| 647 | self.text_pruefen(frage_id, 'ohneFundstelleGrund', grund, MIN_GRUND, 400) |
| 648 | elif grund is not None: |
| 649 | self.melden( |
| 650 | frage_id, |
| 651 | 'ohneFundstelleGrund', |
| 652 | 'gesetzt, obwohl Fundstellen vorhanden sind', |
| 653 | ) |
| 654 | |
| 655 | def kernpunkte_pruefen(self, frage_id: str, kernpunkte: object) -> None: |
| 656 | """Prüft die Kernpunkte einer offenen Frage. |
| 657 | |
| 658 | Die Punkte sind eine Einschaetzung dieser Software, keine Aussage |
| 659 | ueber den amtlichen Katalog. Geprueft wird deshalb nur, was sich |
| 660 | maschinell sicher pruefen laesst - und zwei Dinge, die inhaltlich |
| 661 | zaehlen: Sie gehoeren ausschliesslich an offene Fragen (bei einer |
| 662 | Auswahlfrage stehen die Antworten schon da), und sie duerfen nirgends |
| 663 | behaupten, was eine Antwort enthalten *muss*. Genau diese Behauptung |
| 664 | hat `docs/entscheidung-freitext.md` Abschnitt 8 ausgeschlossen. |
| 665 | """ |
| 666 | if kernpunkte is None: |
| 667 | return |
| 668 | |
| 669 | frage = self.fragen.get(frage_id) |
| 670 | if frage is not None and frage['typ'] == 'mc': |
| 671 | self.melden( |
| 672 | frage_id, |
| 673 | 'kernpunkte', |
| 674 | 'nur bei offenen Fragen zulaessig - bei Auswahlfragen stehen die Antworten da', |
| 675 | ) |
| 676 | return |
| 677 | |
| 678 | if not isinstance(kernpunkte, list): |
| 679 | self.melden(frage_id, 'kernpunkte', 'ist keine Liste') |
| 680 | return |
| 681 | |
| 682 | if len(kernpunkte) < 2: |
| 683 | # Eine Pruefliste mit einem Punkt ist kein Werkzeug, sondern Beiwerk. |
| 684 | self.melden(frage_id, 'kernpunkte', 'braucht mindestens zwei Punkte') |
| 685 | return |
| 686 | if len(kernpunkte) > MAX_KERNPUNKTE: |
| 687 | self.melden( |
| 688 | frage_id, 'kernpunkte', f'mehr als {MAX_KERNPUNKTE} Punkte sind keine Liste mehr' |
| 689 | ) |
| 690 | |
| 691 | gesehen: set[str] = set() |
| 692 | for punkt in kernpunkte: |
| 693 | if not isinstance(punkt, str): |
| 694 | self.melden(frage_id, 'kernpunkte', f'{punkt!r} ist keine Zeichenkette') |
| 695 | continue |
| 696 | if punkt in gesehen: |
| 697 | self.melden(frage_id, 'kernpunkte', f'„{punkt}" steht doppelt') |
| 698 | gesehen.add(punkt) |
| 699 | if not punkt.strip(): |
| 700 | self.melden(frage_id, 'kernpunkte', 'leerer Punkt') |
| 701 | elif len(punkt) > MAX_KERNPUNKT_LAENGE: |
| 702 | self.melden( |
| 703 | frage_id, |
| 704 | 'kernpunkte', |
| 705 | f'zu lang ({len(punkt)} Zeichen, hoechstens {MAX_KERNPUNKT_LAENGE})', |
| 706 | ) |
| 707 | if PFLICHTBEHAUPTUNG.search(punkt): |
| 708 | self.melden( |
| 709 | frage_id, |
| 710 | 'kernpunkte', |
| 711 | 'behauptet, was eine Antwort enthalten muss - das ist unbelegt ' |
| 712 | '(entscheidung-freitext.md Abschnitt 8)', |
| 713 | ) |
| 714 | |
| 715 | def verwandt_pruefen(self, frage_id: str, verwandt: object) -> None: |
| 716 | """Prüft die Querverweise einer Erklärung. |
| 717 | |
| 718 | Der Katalog kennt keine Frage-zu-Frage-Beziehung; ``verwandt`` ist eine |
| 719 | redaktionelle Zuordnung dieser Software. Geprüft wird, was sich |
| 720 | maschinell sicher prüfen lässt: Existenz, kein Selbstbezug, keine |
| 721 | Wiederholung. Ob zwei Fragen wirklich zusammengehören, entscheidet die |
| 722 | Redaktion – die Beidseitigkeit prüft :meth:`verweise_abgleichen`, wenn |
| 723 | alle Erklärungen gelesen sind. |
| 724 | """ |
| 725 | if verwandt is None: |
| 726 | return |
| 727 | |
| 728 | if not isinstance(verwandt, list): |
| 729 | self.melden(frage_id, 'verwandt', 'ist keine Liste') |
| 730 | return |
| 731 | |
| 732 | if not verwandt: |
| 733 | # Eine leere Liste ist eine Angabe, die nichts angibt. |
| 734 | self.melden(frage_id, 'verwandt', 'ist leer; dann gehoert das Feld weg') |
| 735 | return |
| 736 | |
| 737 | gesehen: set[str] = set() |
| 738 | for eintrag in verwandt: |
| 739 | if not isinstance(eintrag, str): |
| 740 | self.melden(frage_id, 'verwandt', f'{eintrag!r} ist keine Zeichenkette') |
| 741 | continue |
| 742 | if eintrag == frage_id: |
| 743 | self.melden(frage_id, 'verwandt', 'verweist auf sich selbst') |
| 744 | continue |
| 745 | if eintrag in gesehen: |
| 746 | self.melden(frage_id, 'verwandt', f'{eintrag} steht doppelt') |
| 747 | continue |
| 748 | gesehen.add(eintrag) |
| 749 | if eintrag not in self.fragen: |
| 750 | self.melden(frage_id, 'verwandt', f'{eintrag} gibt es im Katalog nicht') |
| 751 | else: |
| 752 | self.verweise.setdefault(frage_id, set()).add(eintrag) |
| 753 | |
| 754 | def verweise_abgleichen(self) -> None: |
| 755 | """Verlangt, dass jeder Querverweis beidseitig steht. |
| 756 | |
| 757 | Ein einseitiger Verweis ist fast immer ein Versehen beim Schreiben – |
| 758 | und er zeigt die Verbindung nur dem, der zufaellig von der richtigen |
| 759 | Seite kommt. Wer II-34 loest, soll von II-36 erfahren; wer mit II-36 |
| 760 | anfaengt, ebenso. |
| 761 | """ |
| 762 | for frage_id, ziele in sorted(self.verweise.items()): |
| 763 | for ziel in sorted(ziele): |
| 764 | if frage_id not in self.verweise.get(ziel, set()): |
| 765 | self.melden( |
| 766 | frage_id, |
| 767 | 'verwandt', |
| 768 | f'{ziel} wird genannt, nennt aber {frage_id} nicht zurueck', |
| 769 | ) |
| 770 | |
| 771 | def alles_pruefen(self, daten: dict) -> None: |
| 772 | meta = daten.get('meta') |
| 773 | if not isinstance(meta, dict): |
| 774 | self.melden('(meta)', 'meta', 'fehlt') |
| 775 | else: |
| 776 | for pflicht in ('version', 'stand', 'gesetzesstand', 'hinweis'): |
| 777 | if pflicht not in meta: |
| 778 | self.melden('(meta)', pflicht, 'fehlt') |
| 779 | |
| 780 | # Der vermerkte Gesetzesstand muss zum Index passen. Sonst wären |
| 781 | # die Erklärungen gegen etwas anderes geprüft worden, als |
| 782 | # ausgewiesen ist. |
| 783 | for kuerzel, stand in (meta.get('gesetzesstand') or {}).items(): |
| 784 | if kuerzel not in self.gesetze: |
| 785 | self.melden('(meta)', 'gesetzesstand', f'Unbekanntes Gesetz: {kuerzel}') |
| 786 | elif self.gesetze[kuerzel]['stand'] != stand: |
| 787 | self.melden( |
| 788 | '(meta)', |
| 789 | 'gesetzesstand', |
| 790 | f'{kuerzel}: vermerkt „{stand}", Index sagt ' |
| 791 | f'„{self.gesetze[kuerzel]["stand"]}"', |
| 792 | ) |
| 793 | |
| 794 | zu_frage = daten.get('zuFrage') |
| 795 | if not isinstance(zu_frage, dict): |
| 796 | self.melden('(datei)', 'zuFrage', 'fehlt oder ist kein Objekt') |
| 797 | return |
| 798 | |
| 799 | for frage_id, erklaerung in sorted(zu_frage.items()): |
| 800 | if not isinstance(erklaerung, dict): |
| 801 | self.melden(frage_id, '(ganz)', 'ist kein Objekt') |
| 802 | continue |
| 803 | self.erklaerung_pruefen(frage_id, erklaerung) |
| 804 | |
| 805 | # Erst wenn alle gelesen sind: Ein Verweis auf eine spaeter stehende |
| 806 | # Frage waere sonst als einseitig gemeldet worden. |
| 807 | self.verweise_abgleichen() |
| 808 | |
| 809 | |
| 810 | #: Bewusst fehlerhafte Erklärungen. Jede muss beanstandet werden – sonst ist |
| 811 | #: die Prüfung nutzlos, ohne dass es auffiele. |
| 812 | SELBSTTEST: list[tuple[str, dict, str]] = [ |
| 813 | ( |
| 814 | 'erfundener Paragraf', |
| 815 | {'fundstellen': [{'gesetz': 'WaffG', 'norm': '§ 999'}]}, |
| 816 | 'gibt es im WaffG nicht', |
| 817 | ), |
| 818 | ( |
| 819 | 'erfundener Absatz', |
| 820 | {'fundstellen': [{'gesetz': 'StGB', 'norm': '§ 32', 'absatz': '7'}]}, |
| 821 | 'keinen Abs. 7', |
| 822 | ), |
| 823 | ( |
| 824 | 'erfundene Nummer', |
| 825 | {'fundstellen': [{'gesetz': 'WaffG', 'norm': '§ 12', 'absatz': '1', 'nummer': '99'}]}, |
| 826 | 'keine Nr. 99', |
| 827 | ), |
| 828 | ( |
| 829 | 'erfundener Buchstabe', |
| 830 | { |
| 831 | 'fundstellen': [ |
| 832 | { |
| 833 | 'gesetz': 'WaffG', |
| 834 | 'norm': '§ 12', |
| 835 | 'absatz': '1', |
| 836 | 'nummer': '1', |
| 837 | 'buchstabe': 'z', |
| 838 | } |
| 839 | ] |
| 840 | }, |
| 841 | 'keinen Buchst. z', |
| 842 | ), |
| 843 | ( |
| 844 | 'Absatz als Zahl statt als Zeichenkette', |
| 845 | {'fundstellen': [{'gesetz': 'StGB', 'norm': '§ 32', 'absatz': 1}]}, |
| 846 | 'muss eine Zeichenkette sein', |
| 847 | ), |
| 848 | ( |
| 849 | 'unbekanntes Gesetz', |
| 850 | {'fundstellen': [{'gesetz': 'BGB', 'norm': '§ 1'}]}, |
| 851 | 'Unbekanntes Gesetz', |
| 852 | ), |
| 853 | ( |
| 854 | 'Nummer in einem ungegliederten Absatz', |
| 855 | {'fundstellen': [{'gesetz': 'StGB', 'norm': '§ 32', 'absatz': '1', 'nummer': '1'}]}, |
| 856 | 'nicht in Nummern gegliedert', |
| 857 | ), |
| 858 | ( |
| 859 | 'Buchstabe ohne Nummer, wo der Absatz mit Nummern aufzaehlt', |
| 860 | {'fundstellen': [{'gesetz': 'WaffG', 'norm': '§ 12', 'absatz': '1', 'buchstabe': 'a'}]}, |
| 861 | 'nicht unmittelbar mit Buchstaben', |
| 862 | ), |
| 863 | ( |
| 864 | 'Buchstabe als Nummer ausgegeben', |
| 865 | {'fundstellen': [{'gesetz': 'SprengG', 'norm': '§ 3a', 'absatz': '2', 'nummer': 'a'}]}, |
| 866 | 'muss eine Zahl sein', |
| 867 | ), |
| 868 | ( |
| 869 | 'Absatzangabe bei einer Anlage', |
| 870 | {'fundstellen': [{'gesetz': 'WaffG', 'norm': 'Anlage 1', 'absatz': '1'}]}, |
| 871 | 'nicht zulässig', |
| 872 | ), |
| 873 | ( |
| 874 | 'Anlage, die es nicht gibt', |
| 875 | {'fundstellen': [{'gesetz': 'BeschussV', 'norm': 'Anlage IX'}]}, |
| 876 | 'gibt es im BeschussV nicht', |
| 877 | ), |
| 878 | ( |
| 879 | 'Stelle, die in der Anlage nicht vorkommt', |
| 880 | { |
| 881 | 'fundstellen': [ |
| 882 | {'gesetz': 'WaffG', 'norm': 'Anlage 1', 'stelle': 'Abschnitt 1 Nr. 99.99'} |
| 883 | ] |
| 884 | }, |
| 885 | 'kommt in Anlage 1', |
| 886 | ), |
| 887 | ( |
| 888 | 'gegliederte Norm ohne Absatzangabe', |
| 889 | {'fundstellen': [{'gesetz': 'AWaffV', 'norm': '§ 13'}]}, |
| 890 | 'bitte den zutreffenden angeben', |
| 891 | ), |
| 892 | # Die folgenden Fälle liefen früher stumm durch: Der Satz-Check war nur |
| 893 | # hinter Nummer UND Buchstabe erreichbar, Feinangaben ohne Absatz kehrten |
| 894 | # vor jeder Gliederungsprüfung um, und eine Anlage ohne Stelle galt als |
| 895 | # geprüft, obwohl nur ihre Existenz nachgewiesen war. |
| 896 | ( |
| 897 | 'erfundener Satz ohne Buchstaben-Angabe', |
| 898 | {'fundstellen': [{'gesetz': 'StGB', 'norm': '§ 32', 'absatz': '1', 'satz': '99'}]}, |
| 899 | 'keinen Satz 99', |
| 900 | ), |
| 901 | ( |
| 902 | 'Satz, der keine Zahl ist', |
| 903 | {'fundstellen': [{'gesetz': 'StGB', 'norm': '§ 32', 'absatz': '1', 'satz': 'zwei'}]}, |
| 904 | 'Satz muss eine Zahl ab 1 sein', |
| 905 | ), |
| 906 | ( |
| 907 | 'erfundene Nummer in einer Norm ohne Absatzgliederung', |
| 908 | {'fundstellen': [{'gesetz': 'WaffG', 'norm': '§ 37a', 'nummer': '99'}]}, |
| 909 | 'keine Nr. 99', |
| 910 | ), |
| 911 | ( |
| 912 | 'erfundener Buchstabe in einer Norm ohne Absatzgliederung', |
| 913 | {'fundstellen': [{'gesetz': 'WaffG', 'norm': '§ 37a', 'nummer': '3', 'buchstabe': 'z'}]}, |
| 914 | 'keinen Buchst. z', |
| 915 | ), |
| 916 | ( |
| 917 | 'Anlage ohne Stellenangabe', |
| 918 | {'fundstellen': [{'gesetz': 'WaffG', 'norm': 'Anlage 1'}]}, |
| 919 | 'nur die Existenz', |
| 920 | ), |
| 921 | ( |
| 922 | 'Fundstelle fehlt ohne Begründung', |
| 923 | {'fundstellen': []}, |
| 924 | 'ohneFundstelleGrund', |
| 925 | ), |
| 926 | ( |
| 927 | 'Begründung trotz vorhandener Fundstelle', |
| 928 | { |
| 929 | 'fundstellen': [{'gesetz': 'StGB', 'norm': '§ 32', 'absatz': '1'}], |
| 930 | 'ohneFundstelleGrund': 'Es gibt hierzu keine gesetzliche Regelung im engeren Sinne.', |
| 931 | }, |
| 932 | 'obwohl Fundstellen vorhanden', |
| 933 | ), |
| 934 | ( |
| 935 | 'Platzhalter im Text', |
| 936 | { |
| 937 | # Bewusst lang genug, damit der Fall an der Platzhalterpruefung |
| 938 | # scheitert und nicht schon an der Mindestlaenge. |
| 939 | 'text': ( |
| 940 | 'TODO: Die Begruendung zu dieser Frage fehlt noch und muss vor der ' |
| 941 | 'Auslieferung ergaenzt werden, damit hier nichts Unfertiges steht.' |
| 942 | ) |
| 943 | }, |
| 944 | 'Platzhalter', |
| 945 | ), |
| 946 | ('unbekanntes Feld', {'quelle': 'irgendwoher'}, 'Unbekanntes Feld'), |
| 947 | ('erfundene verwandte Frage', {'verwandt': ['XI.9-99']}, 'gibt es im Katalog nicht'), |
| 948 | ('verwandt als Text statt Liste', {'verwandt': 'II-34'}, 'ist keine Liste'), |
| 949 | ('leere Verwandtenliste', {'verwandt': []}, 'ist leer'), |
| 950 | ] |
| 951 | |
| 952 | |
| 953 | def selbsttest(katalog: dict, gesetze: dict) -> int: |
| 954 | """Prüft die Prüfung. |
| 955 | |
| 956 | Eine Prüfung, die nichts beanstandet, ist von einer funktionierenden nicht |
| 957 | zu unterscheiden – bis jemand sich auf sie verlässt. Deshalb wird jede |
| 958 | Fehlerart einmal absichtlich erzeugt. |
| 959 | """ |
| 960 | # Eine gültige Erklärung als Ausgangspunkt; jeder Fall ändert genau eines. |
| 961 | frage_id = katalog['fragen'][0]['id'] |
| 962 | gueltig = { |
| 963 | 'kurz': 'Ein Satz, der die richtige Antwort in ausreichender Laenge traegt.', |
| 964 | 'text': ( |
| 965 | 'Eine Begruendung von hinreichender Laenge, damit die Formpruefung sie ' |
| 966 | 'nicht wegen Kuerze beanstandet und der Fall wirklich an der gemeinten ' |
| 967 | 'Stelle scheitert.' |
| 968 | ), |
| 969 | 'fundstellen': [{'gesetz': 'StGB', 'norm': '§ 32', 'absatz': '1'}], |
| 970 | } |
| 971 | |
| 972 | fehlgeschlagen = 0 |
| 973 | # Mitgezaehlt statt am Ende gerechnet: Die Schlusszeile nannte bis Fassung |
| 974 | # 0.24.1 `len(SELBSTTEST) + 1` und damit 26 Faelle, gefahren wurden 32 - |
| 975 | # nach der Liste laufen sieben weitere Faelle einzeln. Wer die Zahl als |
| 976 | # Mass der Abdeckung nahm, unterschaetzte sie um sechs; und faellt einmal |
| 977 | # ein Fall aus der Liste heraus, sagte sie darueber gar nichts. Dasselbe |
| 978 | # Muster wie in pruefe_glossar.py. |
| 979 | gelaufen = 0 |
| 980 | for name, aenderung, erwartet in SELBSTTEST: |
| 981 | gelaufen += 1 |
| 982 | fall = {**gueltig, **aenderung} |
| 983 | pruefung = Pruefung(katalog, gesetze) |
| 984 | pruefung.erklaerung_pruefen(frage_id, fall) |
| 985 | |
| 986 | getroffen = any(erwartet in b.meldung for b in pruefung.befunde) |
| 987 | if getroffen: |
| 988 | print(f' erkannt: {name}') |
| 989 | else: |
| 990 | gemeldet = '; '.join(b.meldung for b in pruefung.befunde) or '(nichts beanstandet)' |
| 991 | print(f' DURCHGELASSEN: {name}') |
| 992 | print(f' erwartet: {erwartet}') |
| 993 | print(f' gemeldet: {gemeldet}') |
| 994 | fehlgeschlagen += 1 |
| 995 | |
| 996 | # Kernpunkte gehoeren nur an offene Fragen - der Fall braucht deshalb eine |
| 997 | # Auswahlfrage und passt nicht in die Liste oben, die gegen die erste |
| 998 | # Frage des Katalogs laeuft. |
| 999 | mc_id = next(f['id'] for f in katalog['fragen'] if f['typ'] == 'mc') |
| 1000 | pruefung = Pruefung(katalog, gesetze) |
| 1001 | gelaufen += 1 |
| 1002 | pruefung.erklaerung_pruefen(mc_id, {**gueltig, 'kernpunkte': ['a', 'b']}) |
| 1003 | if any('nur bei offenen Fragen' in b.meldung for b in pruefung.befunde): |
| 1004 | print(' erkannt: Kernpunkte an einer Auswahlfrage') |
| 1005 | else: |
| 1006 | print(' DURCHGELASSEN: Kernpunkte an einer Auswahlfrage') |
| 1007 | fehlgeschlagen += 1 |
| 1008 | |
| 1009 | # Und die Formulierung, die hier nie stehen darf. |
| 1010 | pruefung = Pruefung(katalog, gesetze) |
| 1011 | gelaufen += 1 |
| 1012 | pruefung.erklaerung_pruefen( |
| 1013 | frage_id, {**gueltig, 'kernpunkte': ['das muss Ihre Antwort enthalten', 'b']} |
| 1014 | ) |
| 1015 | if any('unbelegt' in b.meldung for b in pruefung.befunde): |
| 1016 | print(' erkannt: Pflichtbehauptung in einem Kernpunkt') |
| 1017 | else: |
| 1018 | print(' DURCHGELASSEN: Pflichtbehauptung in einem Kernpunkt') |
| 1019 | fehlgeschlagen += 1 |
| 1020 | |
| 1021 | # Eine Pruefliste mit einem Punkt ist kein Werkzeug. |
| 1022 | pruefung = Pruefung(katalog, gesetze) |
| 1023 | gelaufen += 1 |
| 1024 | pruefung.erklaerung_pruefen(frage_id, {**gueltig, 'kernpunkte': ['nur einer']}) |
| 1025 | if any('mindestens zwei' in b.meldung for b in pruefung.befunde): |
| 1026 | print(' erkannt: einzelner Kernpunkt') |
| 1027 | else: |
| 1028 | print(' DURCHGELASSEN: einzelner Kernpunkt') |
| 1029 | fehlgeschlagen += 1 |
| 1030 | |
| 1031 | # Der Selbstbezug braucht die eigene Kennung und laesst sich deshalb nicht |
| 1032 | # als feste Zeichenkette in die Liste oben schreiben. |
| 1033 | pruefung = Pruefung(katalog, gesetze) |
| 1034 | gelaufen += 1 |
| 1035 | pruefung.erklaerung_pruefen(frage_id, {**gueltig, 'verwandt': [frage_id]}) |
| 1036 | if any('sich selbst' in b.meldung for b in pruefung.befunde): |
| 1037 | print(' erkannt: Verweis auf sich selbst') |
| 1038 | else: |
| 1039 | print(' DURCHGELASSEN: Verweis auf sich selbst') |
| 1040 | fehlgeschlagen += 1 |
| 1041 | |
| 1042 | # Und die Beidseitigkeit: Sie braucht zwei Erklaerungen und passt deshalb |
| 1043 | # nicht in die Einzelfallliste. |
| 1044 | zweite_id = katalog['fragen'][1]['id'] |
| 1045 | pruefung = Pruefung(katalog, gesetze) |
| 1046 | gelaufen += 1 |
| 1047 | pruefung.alles_pruefen( |
| 1048 | { |
| 1049 | 'meta': {'version': 1, 'stand': '2026-01-01', 'gesetzesstand': {}, 'hinweis': 'x'}, |
| 1050 | 'zuFrage': { |
| 1051 | frage_id: {**gueltig, 'verwandt': [zweite_id]}, |
| 1052 | zweite_id: dict(gueltig), |
| 1053 | }, |
| 1054 | } |
| 1055 | ) |
| 1056 | if any('nennt aber' in b.meldung for b in pruefung.befunde): |
| 1057 | print(' erkannt: einseitiger Querverweis') |
| 1058 | else: |
| 1059 | print(' DURCHGELASSEN: einseitiger Querverweis') |
| 1060 | fehlgeschlagen += 1 |
| 1061 | |
| 1062 | # Beidseitig gesetzt darf derselbe Fall nicht beanstandet werden. |
| 1063 | pruefung = Pruefung(katalog, gesetze) |
| 1064 | gelaufen += 1 |
| 1065 | pruefung.alles_pruefen( |
| 1066 | { |
| 1067 | 'meta': {'version': 1, 'stand': '2026-01-01', 'gesetzesstand': {}, 'hinweis': 'x'}, |
| 1068 | 'zuFrage': { |
| 1069 | frage_id: {**gueltig, 'verwandt': [zweite_id]}, |
| 1070 | zweite_id: {**gueltig, 'verwandt': [frage_id]}, |
| 1071 | }, |
| 1072 | } |
| 1073 | ) |
| 1074 | if any('verwandt' == b.feld for b in pruefung.befunde): |
| 1075 | print(' FALSCHER ALARM bei beidseitigem Querverweis') |
| 1076 | fehlgeschlagen += 1 |
| 1077 | else: |
| 1078 | print(' ok: beidseitiger Querverweis bleibt unbeanstandet') |
| 1079 | |
| 1080 | # Und die gueltige Erklaerung darf nicht beanstandet werden. |
| 1081 | pruefung = Pruefung(katalog, gesetze) |
| 1082 | gelaufen += 1 |
| 1083 | pruefung.erklaerung_pruefen(frage_id, gueltig) |
| 1084 | if pruefung.befunde: |
| 1085 | print(' FALSCHER ALARM bei einer gueltigen Erklaerung:') |
| 1086 | for befund in pruefung.befunde: |
| 1087 | print(f' {befund}') |
| 1088 | fehlgeschlagen += 1 |
| 1089 | else: |
| 1090 | print(' erkannt: gueltige Erklaerung bleibt unbeanstandet') |
| 1091 | |
| 1092 | if fehlgeschlagen: |
| 1093 | print(f'\nSelbsttest fehlgeschlagen: {fehlgeschlagen} Faelle.') |
| 1094 | return 1 |
| 1095 | |
| 1096 | print(f'\nSelbsttest bestanden: {gelaufen} Faelle.') |
| 1097 | return 0 |
| 1098 | |
| 1099 | |
| 1100 | def main() -> int: |
| 1101 | katalog_test = '--selbsttest' in sys.argv |
| 1102 | |
| 1103 | if katalog_test: |
| 1104 | katalog = json.load(io.open(KATALOG, encoding='utf-8')) |
| 1105 | gesetze = json.load(io.open(GESETZE, encoding='utf-8')) |
| 1106 | return selbsttest(katalog, gesetze) |
| 1107 | |
| 1108 | if not ERKLAERUNGEN.exists(): |
| 1109 | print(f'Keine Erklärungen gefunden: {ERKLAERUNGEN.relative_to(WURZEL)}') |
| 1110 | print('Das ist zulässig – die Anwendung läuft auch ohne.') |
| 1111 | return 0 |
| 1112 | |
| 1113 | katalog = json.load(io.open(KATALOG, encoding='utf-8')) |
| 1114 | gesetze = json.load(io.open(GESETZE, encoding='utf-8')) |
| 1115 | daten = json.load(io.open(ERKLAERUNGEN, encoding='utf-8')) |
| 1116 | |
| 1117 | pruefung = Pruefung(katalog, gesetze) |
| 1118 | pruefung.alles_pruefen(daten) |
| 1119 | |
| 1120 | zu_frage = daten.get('zuFrage') or {} |
| 1121 | gesamt = len(katalog['fragen']) |
| 1122 | abgedeckt = len([k for k in zu_frage if k in pruefung.fragen]) |
| 1123 | mit_fundstelle = len([e for e in zu_frage.values() if (e or {}).get('fundstellen')]) |
| 1124 | |
| 1125 | print(f'Fragen im Katalog: {gesamt}') |
| 1126 | print(f'davon mit Erklärung: {abgedeckt} ({abgedeckt / gesamt * 100:.1f} %)') |
| 1127 | print(f'davon mit Fundstelle: {mit_fundstelle}') |
| 1128 | # Zwei Zahlen statt einer: Ein Sammelzähler hatte 188 nur zur Hälfte |
| 1129 | # geprüfte Fundstellen als „geprüft" ausgewiesen. |
| 1130 | print(f'geprüfte Fundstellen: {pruefung.voll_geprueft + pruefung.nur_existenz}') |
| 1131 | print(f' vollständig geprüft: {pruefung.voll_geprueft} (jede Angabe nachgewiesen)') |
| 1132 | print( |
| 1133 | f' nur Existenz geprüft: {pruefung.nur_existenz}' |
| 1134 | ' (Feinangabe maschinell nicht prüfbar)' |
| 1135 | ) |
| 1136 | print(f'zitierte Normen: {len(pruefung.normnutzung)}') |
| 1137 | |
| 1138 | if pruefung.ungeprueft: |
| 1139 | print(f'\nNicht maschinell prüfbar ({len(pruefung.ungeprueft)}):') |
| 1140 | for eintrag in pruefung.ungeprueft[:20]: |
| 1141 | print(f' {eintrag}') |
| 1142 | if len(pruefung.ungeprueft) > 20: |
| 1143 | print(f' … und {len(pruefung.ungeprueft) - 20} weitere') |
| 1144 | |
| 1145 | if pruefung.befunde: |
| 1146 | print(f'\n{len(pruefung.befunde)} Beanstandungen:\n') |
| 1147 | for befund in pruefung.befunde[:200]: |
| 1148 | print(f' {befund}') |
| 1149 | if len(pruefung.befunde) > 200: |
| 1150 | print(f' … und {len(pruefung.befunde) - 200} weitere') |
| 1151 | return 1 |
| 1152 | |
| 1153 | print('\nAlle Fundstellen im amtlichen Gesetzestext nachgewiesen.') |
| 1154 | return 0 |
| 1155 | |
| 1156 | |
| 1157 | if __name__ == '__main__': |
| 1158 | sys.stdout.reconfigure(encoding='utf-8', errors='replace') # type: ignore[union-attr] |
| 1159 | raise SystemExit(main()) |