waffensachkunde

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

/ data-pipeline pruefe_erklaerungen.py

47,4 KB Rohdatei
data-pipeline/pruefe_erklaerungen.py — 1159 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 @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())