waffensachkunde

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

/ data-pipeline pruefe_erklaerungen.py

42,2 KB Rohdatei
data-pipeline/pruefe_erklaerungen.py — 1046 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 def erklaerung_pruefen(self, frage_id: str, erklaerung: dict) -> None:
489 if frage_id not in self.fragen:
490 self.melden(frage_id, 'zuordnung', 'Diese Frage gibt es im Katalog nicht')
491 return
492
493 erlaubt = {
494 'kurz',
495 'text',
496 'fundstellen',
497 'ohneFundstelleGrund',
498 'merksatz',
499 'verwandt',
500 'kernpunkte',
501 }
502 for feld in set(erklaerung) - erlaubt:
503 self.melden(frage_id, feld, 'Unbekanntes Feld')
504
505 self.verwandt_pruefen(frage_id, erklaerung.get('verwandt'))
506 self.kernpunkte_pruefen(frage_id, erklaerung.get('kernpunkte'))
507
508 self.text_pruefen(frage_id, 'kurz', erklaerung.get('kurz'), MIN_KURZ, MAX_KURZ)
509 self.text_pruefen(frage_id, 'text', erklaerung.get('text'), MIN_TEXT, MAX_TEXT)
510
511 if 'merksatz' in erklaerung:
512 self.text_pruefen(frage_id, 'merksatz', erklaerung['merksatz'], 10, MAX_MERKSATZ)
513
514 fundstellen = erklaerung.get('fundstellen')
515 if not isinstance(fundstellen, list):
516 self.melden(frage_id, 'fundstellen', 'fehlt oder ist keine Liste')
517 return
518
519 for index, fundstelle in enumerate(fundstellen):
520 if not isinstance(fundstelle, dict):
521 self.melden(frage_id, f'fundstelle[{index}]', 'ist kein Objekt')
522 continue
523 self.fundstelle_pruefen(frage_id, index, fundstelle)
524
525 grund = erklaerung.get('ohneFundstelleGrund')
526 if not fundstellen:
527 if grund is None:
528 self.melden(
529 frage_id,
530 'fundstellen',
531 'Ohne Fundstelle muss „ohneFundstelleGrund" gesetzt sein',
532 )
533 else:
534 self.text_pruefen(frage_id, 'ohneFundstelleGrund', grund, MIN_GRUND, 400)
535 elif grund is not None:
536 self.melden(
537 frage_id,
538 'ohneFundstelleGrund',
539 'gesetzt, obwohl Fundstellen vorhanden sind',
540 )
541
542 def kernpunkte_pruefen(self, frage_id: str, kernpunkte: object) -> None:
543 """Prüft die Kernpunkte einer offenen Frage.
544
545 Die Punkte sind eine Einschaetzung dieser Software, keine Aussage
546 ueber den amtlichen Katalog. Geprueft wird deshalb nur, was sich
547 maschinell sicher pruefen laesst - und zwei Dinge, die inhaltlich
548 zaehlen: Sie gehoeren ausschliesslich an offene Fragen (bei einer
549 Auswahlfrage stehen die Antworten schon da), und sie duerfen nirgends
550 behaupten, was eine Antwort enthalten *muss*. Genau diese Behauptung
551 hat `docs/entscheidung-freitext.md` Abschnitt 8 ausgeschlossen.
552 """
553 if kernpunkte is None:
554 return
555
556 frage = self.fragen.get(frage_id)
557 if frage is not None and frage['typ'] == 'mc':
558 self.melden(
559 frage_id,
560 'kernpunkte',
561 'nur bei offenen Fragen zulaessig - bei Auswahlfragen stehen die Antworten da',
562 )
563 return
564
565 if not isinstance(kernpunkte, list):
566 self.melden(frage_id, 'kernpunkte', 'ist keine Liste')
567 return
568
569 if len(kernpunkte) < 2:
570 # Eine Pruefliste mit einem Punkt ist kein Werkzeug, sondern Beiwerk.
571 self.melden(frage_id, 'kernpunkte', 'braucht mindestens zwei Punkte')
572 return
573 if len(kernpunkte) > MAX_KERNPUNKTE:
574 self.melden(
575 frage_id, 'kernpunkte', f'mehr als {MAX_KERNPUNKTE} Punkte sind keine Liste mehr'
576 )
577
578 gesehen: set[str] = set()
579 for punkt in kernpunkte:
580 if not isinstance(punkt, str):
581 self.melden(frage_id, 'kernpunkte', f'{punkt!r} ist keine Zeichenkette')
582 continue
583 if punkt in gesehen:
584 self.melden(frage_id, 'kernpunkte', f'„{punkt}" steht doppelt')
585 gesehen.add(punkt)
586 if not punkt.strip():
587 self.melden(frage_id, 'kernpunkte', 'leerer Punkt')
588 elif len(punkt) > MAX_KERNPUNKT_LAENGE:
589 self.melden(
590 frage_id,
591 'kernpunkte',
592 f'zu lang ({len(punkt)} Zeichen, hoechstens {MAX_KERNPUNKT_LAENGE})',
593 )
594 if PFLICHTBEHAUPTUNG.search(punkt):
595 self.melden(
596 frage_id,
597 'kernpunkte',
598 'behauptet, was eine Antwort enthalten muss - das ist unbelegt '
599 '(entscheidung-freitext.md Abschnitt 8)',
600 )
601
602 def verwandt_pruefen(self, frage_id: str, verwandt: object) -> None:
603 """Prüft die Querverweise einer Erklärung.
604
605 Der Katalog kennt keine Frage-zu-Frage-Beziehung; ``verwandt`` ist eine
606 redaktionelle Zuordnung dieser Software. Geprüft wird, was sich
607 maschinell sicher prüfen lässt: Existenz, kein Selbstbezug, keine
608 Wiederholung. Ob zwei Fragen wirklich zusammengehören, entscheidet die
609 Redaktion – die Beidseitigkeit prüft :meth:`verweise_abgleichen`, wenn
610 alle Erklärungen gelesen sind.
611 """
612 if verwandt is None:
613 return
614
615 if not isinstance(verwandt, list):
616 self.melden(frage_id, 'verwandt', 'ist keine Liste')
617 return
618
619 if not verwandt:
620 # Eine leere Liste ist eine Angabe, die nichts angibt.
621 self.melden(frage_id, 'verwandt', 'ist leer; dann gehoert das Feld weg')
622 return
623
624 gesehen: set[str] = set()
625 for eintrag in verwandt:
626 if not isinstance(eintrag, str):
627 self.melden(frage_id, 'verwandt', f'{eintrag!r} ist keine Zeichenkette')
628 continue
629 if eintrag == frage_id:
630 self.melden(frage_id, 'verwandt', 'verweist auf sich selbst')
631 continue
632 if eintrag in gesehen:
633 self.melden(frage_id, 'verwandt', f'{eintrag} steht doppelt')
634 continue
635 gesehen.add(eintrag)
636 if eintrag not in self.fragen:
637 self.melden(frage_id, 'verwandt', f'{eintrag} gibt es im Katalog nicht')
638 else:
639 self.verweise.setdefault(frage_id, set()).add(eintrag)
640
641 def verweise_abgleichen(self) -> None:
642 """Verlangt, dass jeder Querverweis beidseitig steht.
643
644 Ein einseitiger Verweis ist fast immer ein Versehen beim Schreiben –
645 und er zeigt die Verbindung nur dem, der zufaellig von der richtigen
646 Seite kommt. Wer II-34 loest, soll von II-36 erfahren; wer mit II-36
647 anfaengt, ebenso.
648 """
649 for frage_id, ziele in sorted(self.verweise.items()):
650 for ziel in sorted(ziele):
651 if frage_id not in self.verweise.get(ziel, set()):
652 self.melden(
653 frage_id,
654 'verwandt',
655 f'{ziel} wird genannt, nennt aber {frage_id} nicht zurueck',
656 )
657
658 def alles_pruefen(self, daten: dict) -> None:
659 meta = daten.get('meta')
660 if not isinstance(meta, dict):
661 self.melden('(meta)', 'meta', 'fehlt')
662 else:
663 for pflicht in ('version', 'stand', 'gesetzesstand', 'hinweis'):
664 if pflicht not in meta:
665 self.melden('(meta)', pflicht, 'fehlt')
666
667 # Der vermerkte Gesetzesstand muss zum Index passen. Sonst wären
668 # die Erklärungen gegen etwas anderes geprüft worden, als
669 # ausgewiesen ist.
670 for kuerzel, stand in (meta.get('gesetzesstand') or {}).items():
671 if kuerzel not in self.gesetze:
672 self.melden('(meta)', 'gesetzesstand', f'Unbekanntes Gesetz: {kuerzel}')
673 elif self.gesetze[kuerzel]['stand'] != stand:
674 self.melden(
675 '(meta)',
676 'gesetzesstand',
677 f'{kuerzel}: vermerkt „{stand}", Index sagt '
678 f'„{self.gesetze[kuerzel]["stand"]}"',
679 )
680
681 zu_frage = daten.get('zuFrage')
682 if not isinstance(zu_frage, dict):
683 self.melden('(datei)', 'zuFrage', 'fehlt oder ist kein Objekt')
684 return
685
686 for frage_id, erklaerung in sorted(zu_frage.items()):
687 if not isinstance(erklaerung, dict):
688 self.melden(frage_id, '(ganz)', 'ist kein Objekt')
689 continue
690 self.erklaerung_pruefen(frage_id, erklaerung)
691
692 # Erst wenn alle gelesen sind: Ein Verweis auf eine spaeter stehende
693 # Frage waere sonst als einseitig gemeldet worden.
694 self.verweise_abgleichen()
695
696
697 #: Bewusst fehlerhafte Erklärungen. Jede muss beanstandet werden – sonst ist
698 #: die Prüfung nutzlos, ohne dass es auffiele.
699 SELBSTTEST: list[tuple[str, dict, str]] = [
700 (
701 'erfundener Paragraf',
702 {'fundstellen': [{'gesetz': 'WaffG', 'norm': '§ 999'}]},
703 'gibt es im WaffG nicht',
704 ),
705 (
706 'erfundener Absatz',
707 {'fundstellen': [{'gesetz': 'StGB', 'norm': '§ 32', 'absatz': '7'}]},
708 'keinen Abs. 7',
709 ),
710 (
711 'erfundene Nummer',
712 {'fundstellen': [{'gesetz': 'WaffG', 'norm': '§ 12', 'absatz': '1', 'nummer': '99'}]},
713 'keine Nr. 99',
714 ),
715 (
716 'erfundener Buchstabe',
717 {
718 'fundstellen': [
719 {
720 'gesetz': 'WaffG',
721 'norm': '§ 12',
722 'absatz': '1',
723 'nummer': '1',
724 'buchstabe': 'z',
725 }
726 ]
727 },
728 'keinen Buchst. z',
729 ),
730 (
731 'Absatz als Zahl statt als Zeichenkette',
732 {'fundstellen': [{'gesetz': 'StGB', 'norm': '§ 32', 'absatz': 1}]},
733 'muss eine Zeichenkette sein',
734 ),
735 (
736 'unbekanntes Gesetz',
737 {'fundstellen': [{'gesetz': 'BGB', 'norm': '§ 1'}]},
738 'Unbekanntes Gesetz',
739 ),
740 (
741 'Nummer in einem ungegliederten Absatz',
742 {'fundstellen': [{'gesetz': 'StGB', 'norm': '§ 32', 'absatz': '1', 'nummer': '1'}]},
743 'nicht in Nummern gegliedert',
744 ),
745 (
746 'Buchstabe ohne Nummer, wo der Absatz mit Nummern aufzaehlt',
747 {'fundstellen': [{'gesetz': 'WaffG', 'norm': '§ 12', 'absatz': '1', 'buchstabe': 'a'}]},
748 'nicht unmittelbar mit Buchstaben',
749 ),
750 (
751 'Buchstabe als Nummer ausgegeben',
752 {'fundstellen': [{'gesetz': 'SprengG', 'norm': '§ 3a', 'absatz': '2', 'nummer': 'a'}]},
753 'muss eine Zahl sein',
754 ),
755 (
756 'Absatzangabe bei einer Anlage',
757 {'fundstellen': [{'gesetz': 'WaffG', 'norm': 'Anlage 1', 'absatz': '1'}]},
758 'nicht zulässig',
759 ),
760 (
761 'Anlage, die es nicht gibt',
762 {'fundstellen': [{'gesetz': 'BeschussV', 'norm': 'Anlage IX'}]},
763 'gibt es im BeschussV nicht',
764 ),
765 (
766 'Stelle, die in der Anlage nicht vorkommt',
767 {
768 'fundstellen': [
769 {'gesetz': 'WaffG', 'norm': 'Anlage 1', 'stelle': 'Abschnitt 1 Nr. 99.99'}
770 ]
771 },
772 'kommt in Anlage 1',
773 ),
774 (
775 'gegliederte Norm ohne Absatzangabe',
776 {'fundstellen': [{'gesetz': 'AWaffV', 'norm': '§ 13'}]},
777 'bitte den zutreffenden angeben',
778 ),
779 # Die folgenden Fälle liefen früher stumm durch: Der Satz-Check war nur
780 # hinter Nummer UND Buchstabe erreichbar, Feinangaben ohne Absatz kehrten
781 # vor jeder Gliederungsprüfung um, und eine Anlage ohne Stelle galt als
782 # geprüft, obwohl nur ihre Existenz nachgewiesen war.
783 (
784 'erfundener Satz ohne Buchstaben-Angabe',
785 {'fundstellen': [{'gesetz': 'StGB', 'norm': '§ 32', 'absatz': '1', 'satz': '99'}]},
786 'keinen Satz 99',
787 ),
788 (
789 'Satz, der keine Zahl ist',
790 {'fundstellen': [{'gesetz': 'StGB', 'norm': '§ 32', 'absatz': '1', 'satz': 'zwei'}]},
791 'Satz muss eine Zahl ab 1 sein',
792 ),
793 (
794 'erfundene Nummer in einer Norm ohne Absatzgliederung',
795 {'fundstellen': [{'gesetz': 'WaffG', 'norm': '§ 37a', 'nummer': '99'}]},
796 'keine Nr. 99',
797 ),
798 (
799 'erfundener Buchstabe in einer Norm ohne Absatzgliederung',
800 {'fundstellen': [{'gesetz': 'WaffG', 'norm': '§ 37a', 'nummer': '3', 'buchstabe': 'z'}]},
801 'keinen Buchst. z',
802 ),
803 (
804 'Anlage ohne Stellenangabe',
805 {'fundstellen': [{'gesetz': 'WaffG', 'norm': 'Anlage 1'}]},
806 'nur die Existenz',
807 ),
808 (
809 'Fundstelle fehlt ohne Begründung',
810 {'fundstellen': []},
811 'ohneFundstelleGrund',
812 ),
813 (
814 'Begründung trotz vorhandener Fundstelle',
815 {
816 'fundstellen': [{'gesetz': 'StGB', 'norm': '§ 32', 'absatz': '1'}],
817 'ohneFundstelleGrund': 'Es gibt hierzu keine gesetzliche Regelung im engeren Sinne.',
818 },
819 'obwohl Fundstellen vorhanden',
820 ),
821 (
822 'Platzhalter im Text',
823 {
824 # Bewusst lang genug, damit der Fall an der Platzhalterpruefung
825 # scheitert und nicht schon an der Mindestlaenge.
826 'text': (
827 'TODO: Die Begruendung zu dieser Frage fehlt noch und muss vor der '
828 'Auslieferung ergaenzt werden, damit hier nichts Unfertiges steht.'
829 )
830 },
831 'Platzhalter',
832 ),
833 ('unbekanntes Feld', {'quelle': 'irgendwoher'}, 'Unbekanntes Feld'),
834 ('erfundene verwandte Frage', {'verwandt': ['XI.9-99']}, 'gibt es im Katalog nicht'),
835 ('verwandt als Text statt Liste', {'verwandt': 'II-34'}, 'ist keine Liste'),
836 ('leere Verwandtenliste', {'verwandt': []}, 'ist leer'),
837 ]
838
839
840 def selbsttest(katalog: dict, gesetze: dict) -> int:
841 """Prüft die Prüfung.
842
843 Eine Prüfung, die nichts beanstandet, ist von einer funktionierenden nicht
844 zu unterscheiden – bis jemand sich auf sie verlässt. Deshalb wird jede
845 Fehlerart einmal absichtlich erzeugt.
846 """
847 # Eine gültige Erklärung als Ausgangspunkt; jeder Fall ändert genau eines.
848 frage_id = katalog['fragen'][0]['id']
849 gueltig = {
850 'kurz': 'Ein Satz, der die richtige Antwort in ausreichender Laenge traegt.',
851 'text': (
852 'Eine Begruendung von hinreichender Laenge, damit die Formpruefung sie '
853 'nicht wegen Kuerze beanstandet und der Fall wirklich an der gemeinten '
854 'Stelle scheitert.'
855 ),
856 'fundstellen': [{'gesetz': 'StGB', 'norm': '§ 32', 'absatz': '1'}],
857 }
858
859 fehlgeschlagen = 0
860 # Mitgezaehlt statt am Ende gerechnet: Die Schlusszeile nannte bis Fassung
861 # 0.24.1 `len(SELBSTTEST) + 1` und damit 26 Faelle, gefahren wurden 32 -
862 # nach der Liste laufen sieben weitere Faelle einzeln. Wer die Zahl als
863 # Mass der Abdeckung nahm, unterschaetzte sie um sechs; und faellt einmal
864 # ein Fall aus der Liste heraus, sagte sie darueber gar nichts. Dasselbe
865 # Muster wie in pruefe_glossar.py.
866 gelaufen = 0
867 for name, aenderung, erwartet in SELBSTTEST:
868 gelaufen += 1
869 fall = {**gueltig, **aenderung}
870 pruefung = Pruefung(katalog, gesetze)
871 pruefung.erklaerung_pruefen(frage_id, fall)
872
873 getroffen = any(erwartet in b.meldung for b in pruefung.befunde)
874 if getroffen:
875 print(f' erkannt: {name}')
876 else:
877 gemeldet = '; '.join(b.meldung for b in pruefung.befunde) or '(nichts beanstandet)'
878 print(f' DURCHGELASSEN: {name}')
879 print(f' erwartet: {erwartet}')
880 print(f' gemeldet: {gemeldet}')
881 fehlgeschlagen += 1
882
883 # Kernpunkte gehoeren nur an offene Fragen - der Fall braucht deshalb eine
884 # Auswahlfrage und passt nicht in die Liste oben, die gegen die erste
885 # Frage des Katalogs laeuft.
886 mc_id = next(f['id'] for f in katalog['fragen'] if f['typ'] == 'mc')
887 pruefung = Pruefung(katalog, gesetze)
888 gelaufen += 1
889 pruefung.erklaerung_pruefen(mc_id, {**gueltig, 'kernpunkte': ['a', 'b']})
890 if any('nur bei offenen Fragen' in b.meldung for b in pruefung.befunde):
891 print(' erkannt: Kernpunkte an einer Auswahlfrage')
892 else:
893 print(' DURCHGELASSEN: Kernpunkte an einer Auswahlfrage')
894 fehlgeschlagen += 1
895
896 # Und die Formulierung, die hier nie stehen darf.
897 pruefung = Pruefung(katalog, gesetze)
898 gelaufen += 1
899 pruefung.erklaerung_pruefen(
900 frage_id, {**gueltig, 'kernpunkte': ['das muss Ihre Antwort enthalten', 'b']}
901 )
902 if any('unbelegt' in b.meldung for b in pruefung.befunde):
903 print(' erkannt: Pflichtbehauptung in einem Kernpunkt')
904 else:
905 print(' DURCHGELASSEN: Pflichtbehauptung in einem Kernpunkt')
906 fehlgeschlagen += 1
907
908 # Eine Pruefliste mit einem Punkt ist kein Werkzeug.
909 pruefung = Pruefung(katalog, gesetze)
910 gelaufen += 1
911 pruefung.erklaerung_pruefen(frage_id, {**gueltig, 'kernpunkte': ['nur einer']})
912 if any('mindestens zwei' in b.meldung for b in pruefung.befunde):
913 print(' erkannt: einzelner Kernpunkt')
914 else:
915 print(' DURCHGELASSEN: einzelner Kernpunkt')
916 fehlgeschlagen += 1
917
918 # Der Selbstbezug braucht die eigene Kennung und laesst sich deshalb nicht
919 # als feste Zeichenkette in die Liste oben schreiben.
920 pruefung = Pruefung(katalog, gesetze)
921 gelaufen += 1
922 pruefung.erklaerung_pruefen(frage_id, {**gueltig, 'verwandt': [frage_id]})
923 if any('sich selbst' in b.meldung for b in pruefung.befunde):
924 print(' erkannt: Verweis auf sich selbst')
925 else:
926 print(' DURCHGELASSEN: Verweis auf sich selbst')
927 fehlgeschlagen += 1
928
929 # Und die Beidseitigkeit: Sie braucht zwei Erklaerungen und passt deshalb
930 # nicht in die Einzelfallliste.
931 zweite_id = katalog['fragen'][1]['id']
932 pruefung = Pruefung(katalog, gesetze)
933 gelaufen += 1
934 pruefung.alles_pruefen(
935 {
936 'meta': {'version': 1, 'stand': '2026-01-01', 'gesetzesstand': {}, 'hinweis': 'x'},
937 'zuFrage': {
938 frage_id: {**gueltig, 'verwandt': [zweite_id]},
939 zweite_id: dict(gueltig),
940 },
941 }
942 )
943 if any('nennt aber' in b.meldung for b in pruefung.befunde):
944 print(' erkannt: einseitiger Querverweis')
945 else:
946 print(' DURCHGELASSEN: einseitiger Querverweis')
947 fehlgeschlagen += 1
948
949 # Beidseitig gesetzt darf derselbe Fall nicht beanstandet werden.
950 pruefung = Pruefung(katalog, gesetze)
951 gelaufen += 1
952 pruefung.alles_pruefen(
953 {
954 'meta': {'version': 1, 'stand': '2026-01-01', 'gesetzesstand': {}, 'hinweis': 'x'},
955 'zuFrage': {
956 frage_id: {**gueltig, 'verwandt': [zweite_id]},
957 zweite_id: {**gueltig, 'verwandt': [frage_id]},
958 },
959 }
960 )
961 if any('verwandt' == b.feld for b in pruefung.befunde):
962 print(' FALSCHER ALARM bei beidseitigem Querverweis')
963 fehlgeschlagen += 1
964 else:
965 print(' ok: beidseitiger Querverweis bleibt unbeanstandet')
966
967 # Und die gueltige Erklaerung darf nicht beanstandet werden.
968 pruefung = Pruefung(katalog, gesetze)
969 gelaufen += 1
970 pruefung.erklaerung_pruefen(frage_id, gueltig)
971 if pruefung.befunde:
972 print(' FALSCHER ALARM bei einer gueltigen Erklaerung:')
973 for befund in pruefung.befunde:
974 print(f' {befund}')
975 fehlgeschlagen += 1
976 else:
977 print(' erkannt: gueltige Erklaerung bleibt unbeanstandet')
978
979 if fehlgeschlagen:
980 print(f'\nSelbsttest fehlgeschlagen: {fehlgeschlagen} Faelle.')
981 return 1
982
983 print(f'\nSelbsttest bestanden: {gelaufen} Faelle.')
984 return 0
985
986
987 def main() -> int:
988 katalog_test = '--selbsttest' in sys.argv
989
990 if katalog_test:
991 katalog = json.load(io.open(KATALOG, encoding='utf-8'))
992 gesetze = json.load(io.open(GESETZE, encoding='utf-8'))
993 return selbsttest(katalog, gesetze)
994
995 if not ERKLAERUNGEN.exists():
996 print(f'Keine Erklärungen gefunden: {ERKLAERUNGEN.relative_to(WURZEL)}')
997 print('Das ist zulässig – die Anwendung läuft auch ohne.')
998 return 0
999
1000 katalog = json.load(io.open(KATALOG, encoding='utf-8'))
1001 gesetze = json.load(io.open(GESETZE, encoding='utf-8'))
1002 daten = json.load(io.open(ERKLAERUNGEN, encoding='utf-8'))
1003
1004 pruefung = Pruefung(katalog, gesetze)
1005 pruefung.alles_pruefen(daten)
1006
1007 zu_frage = daten.get('zuFrage') or {}
1008 gesamt = len(katalog['fragen'])
1009 abgedeckt = len([k for k in zu_frage if k in pruefung.fragen])
1010 mit_fundstelle = len([e for e in zu_frage.values() if (e or {}).get('fundstellen')])
1011
1012 print(f'Fragen im Katalog: {gesamt}')
1013 print(f'davon mit Erklärung: {abgedeckt} ({abgedeckt / gesamt * 100:.1f} %)')
1014 print(f'davon mit Fundstelle: {mit_fundstelle}')
1015 # Zwei Zahlen statt einer: Ein Sammelzähler hatte 188 nur zur Hälfte
1016 # geprüfte Fundstellen als „geprüft" ausgewiesen.
1017 print(f'geprüfte Fundstellen: {pruefung.voll_geprueft + pruefung.nur_existenz}')
1018 print(f' vollständig geprüft: {pruefung.voll_geprueft} (jede Angabe nachgewiesen)')
1019 print(
1020 f' nur Existenz geprüft: {pruefung.nur_existenz}'
1021 ' (Feinangabe maschinell nicht prüfbar)'
1022 )
1023 print(f'zitierte Normen: {len(pruefung.normnutzung)}')
1024
1025 if pruefung.ungeprueft:
1026 print(f'\nNicht maschinell prüfbar ({len(pruefung.ungeprueft)}):')
1027 for eintrag in pruefung.ungeprueft[:20]:
1028 print(f' {eintrag}')
1029 if len(pruefung.ungeprueft) > 20:
1030 print(f' … und {len(pruefung.ungeprueft) - 20} weitere')
1031
1032 if pruefung.befunde:
1033 print(f'\n{len(pruefung.befunde)} Beanstandungen:\n')
1034 for befund in pruefung.befunde[:200]:
1035 print(f' {befund}')
1036 if len(pruefung.befunde) > 200:
1037 print(f' … und {len(pruefung.befunde) - 200} weitere')
1038 return 1
1039
1040 print('\nAlle Fundstellen im amtlichen Gesetzestext nachgewiesen.')
1041 return 0
1042
1043
1044 if __name__ == '__main__':
1045 sys.stdout.reconfigure(encoding='utf-8', errors='replace') # type: ignore[union-attr]
1046 raise SystemExit(main())