waffensachkunde

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

/ data-pipeline pruefe_erklaerungen.py

52,1 KB Rohdatei
data-pipeline/pruefe_erklaerungen.py — 1262 Zeilen
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 #: Ein Buchstabe, der zu einer Vorschrift gehört, nicht zu einer Antwort:
519 #: „§ 12 Abs. 1 Nr. 3 Buchstabe d WaffG", „(§ 38 Abs. 1 Nr. 1 Buchst. e)".
520 #: Wird vor der Suche nach Optionsbuchstaben entfernt – siehe
521 #: :meth:`_buchstabenbezug`.
522 ZITATBUCHSTABE = re.compile(r'\bBuchst(?:\.|abe[ns]?)?\s+[a-f]\b')
523
524 @staticmethod
525 def _buchstabenbezug(text: str) -> set:
526 """Welche Optionsbuchstaben nennt die Erklärung ausdrücklich?
527
528 Der Hausstil kennt mehrere Formen, und genau daran ist die
529 ursprüngliche Messung gescheitert: Wer nur nach „a)" sucht, hält
530 „das macht Antwort b falsch" für keine Nennung.
531
532 **Was hier nicht mitzählt.** Buchstaben aus Gesetzeszitaten. Bis
533 Fassung 0.27.2 stand in der Liste unten ein Muster für „Buchstabe a",
534 und „Buchst. e)" fiel zusätzlich unter das Muster für „a)". Beides
535 meint eine Buchstabengliederung im Gesetz und nie eine
536 Antwortmöglichkeit: Nachgemessen kommen im Bestand 123 solche
537 Wendungen vor, und **keine einzige** bezeichnet eine Antwort — dafür
538 schreibt der Hausstil „Antwort a", „a)", „„a"" oder „Zu a".
539
540 Die Wirkung war keine Falschmeldung, sondern eine ausbleibende: Der
541 Buchstabenzweig ist der erste und stärkste Ausgang von
542 :meth:`optionen_pruefen`; ein Zitatbuchstabe konnte eine
543 Antwortmöglichkeit als behandelt ausweisen, die im Text gar nicht
544 vorkam. Gemessen galten dadurch 130 statt 121 Erklärungen als „nennt
545 alle Optionsbuchstaben" — neun davon allein wegen eines Zitats.
546 """
547 ohne_zitat = Pruefung.ZITATBUCHSTABE.sub(' ', text)
548 gefunden = set()
549 for muster in (
550 r'(?:^|[^A-Za-zÄÖÜäöü])([a-f])\)',
551 r'„([a-f])“',
552 r'\bAntwort(?:möglichkeit)?\s+([a-f])\b',
553 r'\bZu\s+([a-f])\b',
554 ):
555 gefunden |= set(re.findall(muster, ohne_zitat))
556 return gefunden
557
558 def optionen_pruefen(self, frage_id: str, erklaerung: dict) -> None:
559 """Behandelt die Erklärung jede Antwortmöglichkeit?
560
561 **Warum das hier steht.** Eine Erklärung, die eine Antwortmöglichkeit
562 übergeht, lässt den Lernenden mit der Frage allein, die ihn am
563 meisten beschäftigt: Warum ist gerade die, die ich angekreuzt habe,
564 falsch? Am 01.09.2026 traf das auf **keine einzige** der 471
565 Auswahlfragen zu – erst durch diese Prüfung bleibt das so.
566
567 **Warum die Prüfung so großzügig ist.** Der Hausstil benennt
568 Antwortmöglichkeiten auf zwei Wegen: mit ihrem Buchstaben (121 von
569 471) oder mit ihrem Gegenstand – „die Armbrust scheitert am Lauf".
570 Der zweite Weg ist der bessere: Wer sich die Erklärung vorlesen
571 lässt, hört bei „zu a)" nichts, woran er sich erinnern könnte. Diese
572 Prüfung erkennt deshalb beide und fällt nur dann aus, wenn eine
573 Möglichkeit auf **keinem** der beiden Wege vorkommt.
574 """
575 frage = self.fragen.get(frage_id) or {}
576 optionen = frage.get('optionen') or []
577 if not optionen:
578 return
579
580 text = f"{erklaerung.get('kurz') or ''} {erklaerung.get('text') or ''}"
581 gefaltet = self._falte(text)
582 briefe = self._buchstabenbezug(text)
583 kurz_anfang = self._falte((erklaerung.get('kurz') or '')[:40])
584
585 # Wörter, die in allen Möglichkeiten vorkommen, unterscheiden nichts.
586 woerter_je_option = []
587 for option in optionen:
588 roh = (option.get('inhalt') or {}).get('text') or ''
589 woerter_je_option.append(
590 {w for w in re.findall(r'[a-z]{4,}', self._falte(roh))}
591 - self.OHNE_UNTERSCHEIDUNGSKRAFT
592 - set(self.ANTWORTWOERTER)
593 )
594 gemeinsam = set.intersection(*woerter_je_option) if len(woerter_je_option) > 1 else set()
595
596 for option, woerter in zip(optionen, woerter_je_option):
597 marke = option.get('label') or '?'
598 if marke in briefe:
599 continue
600
601 roh = (option.get('inhalt') or {}).get('text') or ''
602 wortmenge = set(re.findall(r'[a-z]+', self._falte(roh)))
603 if any(w in self.ANTWORTWOERTER and w in kurz_anfang for w in wortmenge):
604 continue
605
606 eigen = woerter - gemeinsam
607 if not eigen:
608 continue
609 # Wortstamm statt Wortgleichheit: „Leuchtspurpatronen" gilt als
610 # behandelt, wenn irgendwo „Leuchtspurmunition" steht.
611 if any(wort[: max(4, min(5, len(wort)))] in gefaltet for wort in eigen):
612 continue
613
614 self.melden(
615 frage_id,
616 f'option[{marke}]',
617 'kommt in der Erklärung weder mit ihrem Buchstaben noch mit ihrem '
618 f'Gegenstand vor: „{roh[:70]}"',
619 )
620
621 def erklaerung_pruefen(self, frage_id: str, erklaerung: dict) -> None:
622 if frage_id not in self.fragen:
623 self.melden(frage_id, 'zuordnung', 'Diese Frage gibt es im Katalog nicht')
624 return
625
626 erlaubt = {
627 'kurz',
628 'text',
629 'fundstellen',
630 'ohneFundstelleGrund',
631 'merksatz',
632 'verwandt',
633 'kernpunkte',
634 }
635 for feld in set(erklaerung) - erlaubt:
636 self.melden(frage_id, feld, 'Unbekanntes Feld')
637
638 self.optionen_pruefen(frage_id, erklaerung)
639 self.verwandt_pruefen(frage_id, erklaerung.get('verwandt'))
640 self.kernpunkte_pruefen(frage_id, erklaerung.get('kernpunkte'))
641
642 self.text_pruefen(frage_id, 'kurz', erklaerung.get('kurz'), MIN_KURZ, MAX_KURZ)
643 self.text_pruefen(frage_id, 'text', erklaerung.get('text'), MIN_TEXT, MAX_TEXT)
644
645 if 'merksatz' in erklaerung:
646 self.text_pruefen(frage_id, 'merksatz', erklaerung['merksatz'], 10, MAX_MERKSATZ)
647
648 fundstellen = erklaerung.get('fundstellen')
649 if not isinstance(fundstellen, list):
650 self.melden(frage_id, 'fundstellen', 'fehlt oder ist keine Liste')
651 return
652
653 for index, fundstelle in enumerate(fundstellen):
654 if not isinstance(fundstelle, dict):
655 self.melden(frage_id, f'fundstelle[{index}]', 'ist kein Objekt')
656 continue
657 self.fundstelle_pruefen(frage_id, index, fundstelle)
658
659 grund = erklaerung.get('ohneFundstelleGrund')
660 if not fundstellen:
661 if grund is None:
662 self.melden(
663 frage_id,
664 'fundstellen',
665 'Ohne Fundstelle muss „ohneFundstelleGrund" gesetzt sein',
666 )
667 else:
668 self.text_pruefen(frage_id, 'ohneFundstelleGrund', grund, MIN_GRUND, 400)
669 elif grund is not None:
670 self.melden(
671 frage_id,
672 'ohneFundstelleGrund',
673 'gesetzt, obwohl Fundstellen vorhanden sind',
674 )
675
676 def kernpunkte_pruefen(self, frage_id: str, kernpunkte: object) -> None:
677 """Prüft die Kernpunkte einer offenen Frage.
678
679 Die Punkte sind eine Einschaetzung dieser Software, keine Aussage
680 ueber den amtlichen Katalog. Geprueft wird deshalb nur, was sich
681 maschinell sicher pruefen laesst - und zwei Dinge, die inhaltlich
682 zaehlen: Sie gehoeren ausschliesslich an offene Fragen (bei einer
683 Auswahlfrage stehen die Antworten schon da), und sie duerfen nirgends
684 behaupten, was eine Antwort enthalten *muss*. Genau diese Behauptung
685 hat `docs/entscheidung-freitext.md` Abschnitt 8 ausgeschlossen.
686 """
687 if kernpunkte is None:
688 return
689
690 frage = self.fragen.get(frage_id)
691 if frage is not None and frage['typ'] == 'mc':
692 self.melden(
693 frage_id,
694 'kernpunkte',
695 'nur bei offenen Fragen zulaessig - bei Auswahlfragen stehen die Antworten da',
696 )
697 return
698
699 if not isinstance(kernpunkte, list):
700 self.melden(frage_id, 'kernpunkte', 'ist keine Liste')
701 return
702
703 if len(kernpunkte) < 2:
704 # Eine Pruefliste mit einem Punkt ist kein Werkzeug, sondern Beiwerk.
705 self.melden(frage_id, 'kernpunkte', 'braucht mindestens zwei Punkte')
706 return
707 if len(kernpunkte) > MAX_KERNPUNKTE:
708 self.melden(
709 frage_id, 'kernpunkte', f'mehr als {MAX_KERNPUNKTE} Punkte sind keine Liste mehr'
710 )
711
712 gesehen: set[str] = set()
713 for punkt in kernpunkte:
714 if not isinstance(punkt, str):
715 self.melden(frage_id, 'kernpunkte', f'{punkt!r} ist keine Zeichenkette')
716 continue
717 if punkt in gesehen:
718 self.melden(frage_id, 'kernpunkte', f'„{punkt}" steht doppelt')
719 gesehen.add(punkt)
720 if not punkt.strip():
721 self.melden(frage_id, 'kernpunkte', 'leerer Punkt')
722 elif len(punkt) > MAX_KERNPUNKT_LAENGE:
723 self.melden(
724 frage_id,
725 'kernpunkte',
726 f'zu lang ({len(punkt)} Zeichen, hoechstens {MAX_KERNPUNKT_LAENGE})',
727 )
728 if PFLICHTBEHAUPTUNG.search(punkt):
729 self.melden(
730 frage_id,
731 'kernpunkte',
732 'behauptet, was eine Antwort enthalten muss - das ist unbelegt '
733 '(entscheidung-freitext.md Abschnitt 8)',
734 )
735
736 def verwandt_pruefen(self, frage_id: str, verwandt: object) -> None:
737 """Prüft die Querverweise einer Erklärung.
738
739 Der Katalog kennt keine Frage-zu-Frage-Beziehung; ``verwandt`` ist eine
740 redaktionelle Zuordnung dieser Software. Geprüft wird, was sich
741 maschinell sicher prüfen lässt: Existenz, kein Selbstbezug, keine
742 Wiederholung. Ob zwei Fragen wirklich zusammengehören, entscheidet die
743 Redaktion – die Beidseitigkeit prüft :meth:`verweise_abgleichen`, wenn
744 alle Erklärungen gelesen sind.
745 """
746 if verwandt is None:
747 return
748
749 if not isinstance(verwandt, list):
750 self.melden(frage_id, 'verwandt', 'ist keine Liste')
751 return
752
753 if not verwandt:
754 # Eine leere Liste ist eine Angabe, die nichts angibt.
755 self.melden(frage_id, 'verwandt', 'ist leer; dann gehoert das Feld weg')
756 return
757
758 gesehen: set[str] = set()
759 for eintrag in verwandt:
760 if not isinstance(eintrag, str):
761 self.melden(frage_id, 'verwandt', f'{eintrag!r} ist keine Zeichenkette')
762 continue
763 if eintrag == frage_id:
764 self.melden(frage_id, 'verwandt', 'verweist auf sich selbst')
765 continue
766 if eintrag in gesehen:
767 self.melden(frage_id, 'verwandt', f'{eintrag} steht doppelt')
768 continue
769 gesehen.add(eintrag)
770 if eintrag not in self.fragen:
771 self.melden(frage_id, 'verwandt', f'{eintrag} gibt es im Katalog nicht')
772 else:
773 self.verweise.setdefault(frage_id, set()).add(eintrag)
774
775 def verweise_abgleichen(self) -> None:
776 """Verlangt, dass jeder Querverweis beidseitig steht.
777
778 Ein einseitiger Verweis ist fast immer ein Versehen beim Schreiben –
779 und er zeigt die Verbindung nur dem, der zufaellig von der richtigen
780 Seite kommt. Wer II-34 loest, soll von II-36 erfahren; wer mit II-36
781 anfaengt, ebenso.
782 """
783 for frage_id, ziele in sorted(self.verweise.items()):
784 for ziel in sorted(ziele):
785 if frage_id not in self.verweise.get(ziel, set()):
786 self.melden(
787 frage_id,
788 'verwandt',
789 f'{ziel} wird genannt, nennt aber {frage_id} nicht zurueck',
790 )
791
792 def alles_pruefen(self, daten: dict) -> None:
793 meta = daten.get('meta')
794 if not isinstance(meta, dict):
795 self.melden('(meta)', 'meta', 'fehlt')
796 else:
797 for pflicht in ('version', 'stand', 'gesetzesstand', 'hinweis'):
798 if pflicht not in meta:
799 self.melden('(meta)', pflicht, 'fehlt')
800
801 # Der vermerkte Gesetzesstand muss zum Index passen. Sonst wären
802 # die Erklärungen gegen etwas anderes geprüft worden, als
803 # ausgewiesen ist.
804 for kuerzel, stand in (meta.get('gesetzesstand') or {}).items():
805 if kuerzel not in self.gesetze:
806 self.melden('(meta)', 'gesetzesstand', f'Unbekanntes Gesetz: {kuerzel}')
807 elif self.gesetze[kuerzel]['stand'] != stand:
808 self.melden(
809 '(meta)',
810 'gesetzesstand',
811 f'{kuerzel}: vermerkt „{stand}", Index sagt '
812 f'„{self.gesetze[kuerzel]["stand"]}"',
813 )
814
815 zu_frage = daten.get('zuFrage')
816 if not isinstance(zu_frage, dict):
817 self.melden('(datei)', 'zuFrage', 'fehlt oder ist kein Objekt')
818 return
819
820 for frage_id, erklaerung in sorted(zu_frage.items()):
821 if not isinstance(erklaerung, dict):
822 self.melden(frage_id, '(ganz)', 'ist kein Objekt')
823 continue
824 self.erklaerung_pruefen(frage_id, erklaerung)
825
826 # Erst wenn alle gelesen sind: Ein Verweis auf eine spaeter stehende
827 # Frage waere sonst als einseitig gemeldet worden.
828 self.verweise_abgleichen()
829
830
831 #: Bewusst fehlerhafte Erklärungen. Jede muss beanstandet werden – sonst ist
832 #: die Prüfung nutzlos, ohne dass es auffiele.
833 SELBSTTEST: list[tuple[str, dict, str]] = [
834 (
835 'erfundener Paragraf',
836 {'fundstellen': [{'gesetz': 'WaffG', 'norm': '§ 999'}]},
837 'gibt es im WaffG nicht',
838 ),
839 (
840 'erfundener Absatz',
841 {'fundstellen': [{'gesetz': 'StGB', 'norm': '§ 32', 'absatz': '7'}]},
842 'keinen Abs. 7',
843 ),
844 (
845 'erfundene Nummer',
846 {'fundstellen': [{'gesetz': 'WaffG', 'norm': '§ 12', 'absatz': '1', 'nummer': '99'}]},
847 'keine Nr. 99',
848 ),
849 (
850 'erfundener Buchstabe',
851 {
852 'fundstellen': [
853 {
854 'gesetz': 'WaffG',
855 'norm': '§ 12',
856 'absatz': '1',
857 'nummer': '1',
858 'buchstabe': 'z',
859 }
860 ]
861 },
862 'keinen Buchst. z',
863 ),
864 (
865 'Absatz als Zahl statt als Zeichenkette',
866 {'fundstellen': [{'gesetz': 'StGB', 'norm': '§ 32', 'absatz': 1}]},
867 'muss eine Zeichenkette sein',
868 ),
869 (
870 'unbekanntes Gesetz',
871 {'fundstellen': [{'gesetz': 'BGB', 'norm': '§ 1'}]},
872 'Unbekanntes Gesetz',
873 ),
874 (
875 'Nummer in einem ungegliederten Absatz',
876 {'fundstellen': [{'gesetz': 'StGB', 'norm': '§ 32', 'absatz': '1', 'nummer': '1'}]},
877 'nicht in Nummern gegliedert',
878 ),
879 (
880 'Buchstabe ohne Nummer, wo der Absatz mit Nummern aufzaehlt',
881 {'fundstellen': [{'gesetz': 'WaffG', 'norm': '§ 12', 'absatz': '1', 'buchstabe': 'a'}]},
882 'nicht unmittelbar mit Buchstaben',
883 ),
884 (
885 'Buchstabe als Nummer ausgegeben',
886 {'fundstellen': [{'gesetz': 'SprengG', 'norm': '§ 3a', 'absatz': '2', 'nummer': 'a'}]},
887 'muss eine Zahl sein',
888 ),
889 (
890 'Absatzangabe bei einer Anlage',
891 {'fundstellen': [{'gesetz': 'WaffG', 'norm': 'Anlage 1', 'absatz': '1'}]},
892 'nicht zulässig',
893 ),
894 (
895 'Anlage, die es nicht gibt',
896 {'fundstellen': [{'gesetz': 'BeschussV', 'norm': 'Anlage IX'}]},
897 'gibt es im BeschussV nicht',
898 ),
899 (
900 'Stelle, die in der Anlage nicht vorkommt',
901 {
902 'fundstellen': [
903 {'gesetz': 'WaffG', 'norm': 'Anlage 1', 'stelle': 'Abschnitt 1 Nr. 99.99'}
904 ]
905 },
906 'kommt in Anlage 1',
907 ),
908 (
909 'gegliederte Norm ohne Absatzangabe',
910 {'fundstellen': [{'gesetz': 'AWaffV', 'norm': '§ 13'}]},
911 'bitte den zutreffenden angeben',
912 ),
913 # Die folgenden Fälle liefen früher stumm durch: Der Satz-Check war nur
914 # hinter Nummer UND Buchstabe erreichbar, Feinangaben ohne Absatz kehrten
915 # vor jeder Gliederungsprüfung um, und eine Anlage ohne Stelle galt als
916 # geprüft, obwohl nur ihre Existenz nachgewiesen war.
917 (
918 'erfundener Satz ohne Buchstaben-Angabe',
919 {'fundstellen': [{'gesetz': 'StGB', 'norm': '§ 32', 'absatz': '1', 'satz': '99'}]},
920 'keinen Satz 99',
921 ),
922 (
923 'Satz, der keine Zahl ist',
924 {'fundstellen': [{'gesetz': 'StGB', 'norm': '§ 32', 'absatz': '1', 'satz': 'zwei'}]},
925 'Satz muss eine Zahl ab 1 sein',
926 ),
927 (
928 'erfundene Nummer in einer Norm ohne Absatzgliederung',
929 {'fundstellen': [{'gesetz': 'WaffG', 'norm': '§ 37a', 'nummer': '99'}]},
930 'keine Nr. 99',
931 ),
932 (
933 'erfundener Buchstabe in einer Norm ohne Absatzgliederung',
934 {'fundstellen': [{'gesetz': 'WaffG', 'norm': '§ 37a', 'nummer': '3', 'buchstabe': 'z'}]},
935 'keinen Buchst. z',
936 ),
937 (
938 'Anlage ohne Stellenangabe',
939 {'fundstellen': [{'gesetz': 'WaffG', 'norm': 'Anlage 1'}]},
940 'nur die Existenz',
941 ),
942 (
943 'Fundstelle fehlt ohne Begründung',
944 {'fundstellen': []},
945 'ohneFundstelleGrund',
946 ),
947 (
948 'Begründung trotz vorhandener Fundstelle',
949 {
950 'fundstellen': [{'gesetz': 'StGB', 'norm': '§ 32', 'absatz': '1'}],
951 'ohneFundstelleGrund': 'Es gibt hierzu keine gesetzliche Regelung im engeren Sinne.',
952 },
953 'obwohl Fundstellen vorhanden',
954 ),
955 (
956 'Platzhalter im Text',
957 {
958 # Bewusst lang genug, damit der Fall an der Platzhalterpruefung
959 # scheitert und nicht schon an der Mindestlaenge.
960 'text': (
961 'TODO: Die Begruendung zu dieser Frage fehlt noch und muss vor der '
962 'Auslieferung ergaenzt werden, damit hier nichts Unfertiges steht.'
963 )
964 },
965 'Platzhalter',
966 ),
967 ('unbekanntes Feld', {'quelle': 'irgendwoher'}, 'Unbekanntes Feld'),
968 ('erfundene verwandte Frage', {'verwandt': ['XI.9-99']}, 'gibt es im Katalog nicht'),
969 ('verwandt als Text statt Liste', {'verwandt': 'II-34'}, 'ist keine Liste'),
970 ('leere Verwandtenliste', {'verwandt': []}, 'ist leer'),
971 ]
972
973
974 def handbuchzahlen_pruefen(fehlerarten: int, faelle: int) -> int:
975 """Die Zahlen im Handbuch und in der CI-Datei gegen den gefahrenen Lauf.
976
977 Das Handbuch nennt die Zahl der Fehlerarten als Mass der Abdeckung dieser
978 Pruefung. Sie stand dort seit Fassung 0.21.1 unveraendert auf 22 - damals
979 die Laenge von :data:`SELBSTTEST` -, waehrend der Lauf inzwischen 30
980 Fehlerarten und zwei Gegenproben fuhr. Wer die Deckung an der Zahl ablas,
981 unterschaetzte sie um acht.
982
983 Genau dieser Fehler war im Zaehler dieser Datei schon einmal behoben - der
984 Kommentar bei ``gelaufen`` haelt es fest -, eine Datei weiter stand er
985 unbemerkt weiter. Deshalb rechnet der Selbsttest die Zahl jetzt selbst
986 nach, statt sich darauf zu verlassen, dass jemand beide Stellen
987 gleichzeitig anfasst.
988 """
989 stellen = (
990 ('data-pipeline/README.md', r'jede der (\d+) Fehlerarten', fehlerarten, 'Fehlerarten'),
991 ('data-pipeline/README.md', r'meldet deshalb (\d+) Fälle', faelle, 'Faelle'),
992 # Die CI-Datei nannte dieselbe Zahl unabhaengig noch einmal - und stand
993 # bis Fassung 0.27.2 auf 23. Wer den Umfang dieser Pruefung dort ablas,
994 # unterschaetzte ihn ebenso.
995 (
996 '.github/workflows/gate.yml',
997 r'seiner (\d+) Fehlerarten einmal absichtlich',
998 fehlerarten,
999 'Fehlerarten',
1000 ),
1001 )
1002
1003 fehler = 0
1004 for datei, muster, erwartet, was in stellen:
1005 text = io.open(WURZEL / datei, encoding='utf-8').read()
1006 treffer = re.search(muster, text)
1007 if treffer is None:
1008 print(f' HANDBUCH: keine Angabe zu {was} in {datei} gefunden')
1009 fehler += 1
1010 elif int(treffer.group(1)) != erwartet:
1011 print(
1012 f' HANDBUCH: {datei} nennt {treffer.group(1)} {was}, '
1013 f'gefahren werden {erwartet}'
1014 )
1015 fehler += 1
1016
1017 return fehler
1018
1019
1020 def selbsttest(katalog: dict, gesetze: dict) -> int:
1021 """Prüft die Prüfung.
1022
1023 Eine Prüfung, die nichts beanstandet, ist von einer funktionierenden nicht
1024 zu unterscheiden – bis jemand sich auf sie verlässt. Deshalb wird jede
1025 Fehlerart einmal absichtlich erzeugt.
1026 """
1027 # Eine gültige Erklärung als Ausgangspunkt; jeder Fall ändert genau eines.
1028 frage_id = katalog['fragen'][0]['id']
1029 gueltig = {
1030 'kurz': 'Ein Satz, der die richtige Antwort in ausreichender Laenge traegt.',
1031 'text': (
1032 'Eine Begruendung von hinreichender Laenge, damit die Formpruefung sie '
1033 'nicht wegen Kuerze beanstandet und der Fall wirklich an der gemeinten '
1034 'Stelle scheitert.'
1035 ),
1036 'fundstellen': [{'gesetz': 'StGB', 'norm': '§ 32', 'absatz': '1'}],
1037 }
1038
1039 fehlgeschlagen = 0
1040 # Mitgezaehlt statt am Ende gerechnet: Die Schlusszeile nannte bis Fassung
1041 # 0.24.1 `len(SELBSTTEST) + 1` und damit 26 Faelle, gefahren wurden 32 -
1042 # nach der Liste laufen sieben weitere Faelle einzeln. Wer die Zahl als
1043 # Mass der Abdeckung nahm, unterschaetzte sie um sechs; und faellt einmal
1044 # ein Fall aus der Liste heraus, sagte sie darueber gar nichts. Dasselbe
1045 # Muster wie in pruefe_glossar.py.
1046 gelaufen = 0
1047 # Nicht jeder Fall ist eine Fehlerart: Zwei Faelle sind Gegenproben, die
1048 # gerade *nicht* beanstandet werden duerfen. Getrennt gezaehlt, weil
1049 # data-pipeline/README.md die Fehlerarten als Mass der Abdeckung nennt -
1050 # und diese Zahl wird jetzt gegen den Lauf geprueft.
1051 gegenproben = 0
1052 for name, aenderung, erwartet in SELBSTTEST:
1053 gelaufen += 1
1054 fall = {**gueltig, **aenderung}
1055 pruefung = Pruefung(katalog, gesetze)
1056 pruefung.erklaerung_pruefen(frage_id, fall)
1057
1058 getroffen = any(erwartet in b.meldung for b in pruefung.befunde)
1059 if getroffen:
1060 print(f' erkannt: {name}')
1061 else:
1062 gemeldet = '; '.join(b.meldung for b in pruefung.befunde) or '(nichts beanstandet)'
1063 print(f' DURCHGELASSEN: {name}')
1064 print(f' erwartet: {erwartet}')
1065 print(f' gemeldet: {gemeldet}')
1066 fehlgeschlagen += 1
1067
1068 # Kernpunkte gehoeren nur an offene Fragen - der Fall braucht deshalb eine
1069 # Auswahlfrage und passt nicht in die Liste oben, die gegen die erste
1070 # Frage des Katalogs laeuft.
1071 mc_id = next(f['id'] for f in katalog['fragen'] if f['typ'] == 'mc')
1072 pruefung = Pruefung(katalog, gesetze)
1073 gelaufen += 1
1074 pruefung.erklaerung_pruefen(mc_id, {**gueltig, 'kernpunkte': ['a', 'b']})
1075 if any('nur bei offenen Fragen' in b.meldung for b in pruefung.befunde):
1076 print(' erkannt: Kernpunkte an einer Auswahlfrage')
1077 else:
1078 print(' DURCHGELASSEN: Kernpunkte an einer Auswahlfrage')
1079 fehlgeschlagen += 1
1080
1081 # Ein Buchstabe aus einem Gesetzeszitat ist keine Nennung einer
1082 # Antwortmoeglichkeit. Der Fall braucht eine Auswahlfrage und passt
1083 # deshalb nicht in die Liste oben.
1084 mc_frage = next(f for f in katalog['fragen'] if f['typ'] == 'mc')
1085 zitat = ' '.join(
1086 f"§ 12 Abs. 1 Nr. 3 Buchstabe {(o.get('label') or '')} WaffG"
1087 for o in mc_frage['optionen']
1088 )
1089 pruefung = Pruefung(katalog, gesetze)
1090 gelaufen += 1
1091 pruefung.erklaerung_pruefen(
1092 mc_frage['id'],
1093 {
1094 **gueltig,
1095 'text': (
1096 'Die Erklaerung nennt keine einzige Antwortmoeglichkeit bei ihrem '
1097 'Gegenstand, sondern zitiert nur Vorschriften mit Buchstabengliederung: '
1098 + zitat
1099 ),
1100 },
1101 )
1102 if any(b.feld.startswith('option[') for b in pruefung.befunde):
1103 print(' erkannt: Buchstabe aus einem Gesetzeszitat statt einer Antwort')
1104 else:
1105 print(' DURCHGELASSEN: Buchstabe aus einem Gesetzeszitat statt einer Antwort')
1106 fehlgeschlagen += 1
1107
1108 # Und die Formulierung, die hier nie stehen darf.
1109 pruefung = Pruefung(katalog, gesetze)
1110 gelaufen += 1
1111 pruefung.erklaerung_pruefen(
1112 frage_id, {**gueltig, 'kernpunkte': ['das muss Ihre Antwort enthalten', 'b']}
1113 )
1114 if any('unbelegt' in b.meldung for b in pruefung.befunde):
1115 print(' erkannt: Pflichtbehauptung in einem Kernpunkt')
1116 else:
1117 print(' DURCHGELASSEN: Pflichtbehauptung in einem Kernpunkt')
1118 fehlgeschlagen += 1
1119
1120 # Eine Pruefliste mit einem Punkt ist kein Werkzeug.
1121 pruefung = Pruefung(katalog, gesetze)
1122 gelaufen += 1
1123 pruefung.erklaerung_pruefen(frage_id, {**gueltig, 'kernpunkte': ['nur einer']})
1124 if any('mindestens zwei' in b.meldung for b in pruefung.befunde):
1125 print(' erkannt: einzelner Kernpunkt')
1126 else:
1127 print(' DURCHGELASSEN: einzelner Kernpunkt')
1128 fehlgeschlagen += 1
1129
1130 # Der Selbstbezug braucht die eigene Kennung und laesst sich deshalb nicht
1131 # als feste Zeichenkette in die Liste oben schreiben.
1132 pruefung = Pruefung(katalog, gesetze)
1133 gelaufen += 1
1134 pruefung.erklaerung_pruefen(frage_id, {**gueltig, 'verwandt': [frage_id]})
1135 if any('sich selbst' in b.meldung for b in pruefung.befunde):
1136 print(' erkannt: Verweis auf sich selbst')
1137 else:
1138 print(' DURCHGELASSEN: Verweis auf sich selbst')
1139 fehlgeschlagen += 1
1140
1141 # Und die Beidseitigkeit: Sie braucht zwei Erklaerungen und passt deshalb
1142 # nicht in die Einzelfallliste.
1143 zweite_id = katalog['fragen'][1]['id']
1144 pruefung = Pruefung(katalog, gesetze)
1145 gelaufen += 1
1146 pruefung.alles_pruefen(
1147 {
1148 'meta': {'version': 1, 'stand': '2026-01-01', 'gesetzesstand': {}, 'hinweis': 'x'},
1149 'zuFrage': {
1150 frage_id: {**gueltig, 'verwandt': [zweite_id]},
1151 zweite_id: dict(gueltig),
1152 },
1153 }
1154 )
1155 if any('nennt aber' in b.meldung for b in pruefung.befunde):
1156 print(' erkannt: einseitiger Querverweis')
1157 else:
1158 print(' DURCHGELASSEN: einseitiger Querverweis')
1159 fehlgeschlagen += 1
1160
1161 # Beidseitig gesetzt darf derselbe Fall nicht beanstandet werden.
1162 pruefung = Pruefung(katalog, gesetze)
1163 gelaufen += 1
1164 pruefung.alles_pruefen(
1165 {
1166 'meta': {'version': 1, 'stand': '2026-01-01', 'gesetzesstand': {}, 'hinweis': 'x'},
1167 'zuFrage': {
1168 frage_id: {**gueltig, 'verwandt': [zweite_id]},
1169 zweite_id: {**gueltig, 'verwandt': [frage_id]},
1170 },
1171 }
1172 )
1173 gegenproben += 1
1174 if any('verwandt' == b.feld for b in pruefung.befunde):
1175 print(' FALSCHER ALARM bei beidseitigem Querverweis')
1176 fehlgeschlagen += 1
1177 else:
1178 print(' ok: beidseitiger Querverweis bleibt unbeanstandet')
1179
1180 # Und die gueltige Erklaerung darf nicht beanstandet werden.
1181 pruefung = Pruefung(katalog, gesetze)
1182 gelaufen += 1
1183 pruefung.erklaerung_pruefen(frage_id, gueltig)
1184 gegenproben += 1
1185 if pruefung.befunde:
1186 print(' FALSCHER ALARM bei einer gueltigen Erklaerung:')
1187 for befund in pruefung.befunde:
1188 print(f' {befund}')
1189 fehlgeschlagen += 1
1190 else:
1191 print(' ok: gueltige Erklaerung bleibt unbeanstandet')
1192
1193 fehlgeschlagen += handbuchzahlen_pruefen(gelaufen - gegenproben, gelaufen)
1194
1195 if fehlgeschlagen:
1196 print(f'\nSelbsttest fehlgeschlagen: {fehlgeschlagen} Faelle.')
1197 return 1
1198
1199 print(f'\nSelbsttest bestanden: {gelaufen} Faelle.')
1200 return 0
1201
1202
1203 def main() -> int:
1204 katalog_test = '--selbsttest' in sys.argv
1205
1206 if katalog_test:
1207 katalog = json.load(io.open(KATALOG, encoding='utf-8'))
1208 gesetze = json.load(io.open(GESETZE, encoding='utf-8'))
1209 return selbsttest(katalog, gesetze)
1210
1211 if not ERKLAERUNGEN.exists():
1212 print(f'Keine Erklärungen gefunden: {ERKLAERUNGEN.relative_to(WURZEL)}')
1213 print('Das ist zulässig – die Anwendung läuft auch ohne.')
1214 return 0
1215
1216 katalog = json.load(io.open(KATALOG, encoding='utf-8'))
1217 gesetze = json.load(io.open(GESETZE, encoding='utf-8'))
1218 daten = json.load(io.open(ERKLAERUNGEN, encoding='utf-8'))
1219
1220 pruefung = Pruefung(katalog, gesetze)
1221 pruefung.alles_pruefen(daten)
1222
1223 zu_frage = daten.get('zuFrage') or {}
1224 gesamt = len(katalog['fragen'])
1225 abgedeckt = len([k for k in zu_frage if k in pruefung.fragen])
1226 mit_fundstelle = len([e for e in zu_frage.values() if (e or {}).get('fundstellen')])
1227
1228 print(f'Fragen im Katalog: {gesamt}')
1229 print(f'davon mit Erklärung: {abgedeckt} ({abgedeckt / gesamt * 100:.1f} %)')
1230 print(f'davon mit Fundstelle: {mit_fundstelle}')
1231 # Zwei Zahlen statt einer: Ein Sammelzähler hatte 188 nur zur Hälfte
1232 # geprüfte Fundstellen als „geprüft" ausgewiesen.
1233 print(f'geprüfte Fundstellen: {pruefung.voll_geprueft + pruefung.nur_existenz}')
1234 print(f' vollständig geprüft: {pruefung.voll_geprueft} (jede Angabe nachgewiesen)')
1235 print(
1236 f' nur Existenz geprüft: {pruefung.nur_existenz}'
1237 ' (Feinangabe maschinell nicht prüfbar)'
1238 )
1239 print(f'zitierte Normen: {len(pruefung.normnutzung)}')
1240
1241 if pruefung.ungeprueft:
1242 print(f'\nNicht maschinell prüfbar ({len(pruefung.ungeprueft)}):')
1243 for eintrag in pruefung.ungeprueft[:20]:
1244 print(f' {eintrag}')
1245 if len(pruefung.ungeprueft) > 20:
1246 print(f' … und {len(pruefung.ungeprueft) - 20} weitere')
1247
1248 if pruefung.befunde:
1249 print(f'\n{len(pruefung.befunde)} Beanstandungen:\n')
1250 for befund in pruefung.befunde[:200]:
1251 print(f' {befund}')
1252 if len(pruefung.befunde) > 200:
1253 print(f' … und {len(pruefung.befunde) - 200} weitere')
1254 return 1
1255
1256 print('\nAlle Fundstellen im amtlichen Gesetzestext nachgewiesen.')
1257 return 0
1258
1259
1260 if __name__ == '__main__':
1261 sys.stdout.reconfigure(encoding='utf-8', errors='replace') # type: ignore[union-attr]
1262 raise SystemExit(main())