"""Prüft die Erklärungstexte gegen Katalog und amtlichen Gesetzestext. Diese Prüfung entscheidet, ob Erklärungen ausgeliefert werden dürfen. Sie bricht mit Rückgabewert 1 ab, sobald etwas nicht stimmt, und eignet sich damit für eine Baupipeline. Was geprüft wird ---------------- 1. **Zuordnung** – jede Erklärung gehört zu einer Frage, die es im Katalog wirklich gibt. 2. **Fundstellen** – jedes zitierte Gesetz, jeder Paragraf, jeder Absatz, jede Nummer und jeder Buchstabe wird im Index aus `content/gesetze/index.json` nachgeschlagen. Was dort nicht steht, existiert nicht. Bei Normen ohne Absatzgliederung (etwa § 37a WaffG) stehen Nummern und Buchstaben nur im Normtext; geprüft wird dann gegen dessen Aufzählungsmarken. Satzangaben sind im XML nicht ausgezeichnet und werden als Plausibilitätsgrenze geprüft (mehr Sätze als der Text hergibt, kann die Angabe nicht meinen). 3. **Anlagen** – bei Anlagen greift die Absatzstruktur nicht. Geprüft wird, ob die angegebene Stelle im Volltext der Anlage vorkommt. Ohne Stellenangabe wäre nur die Existenz der Anlage nachgewiesen – das wird beanstandet. 4. **Redliche Lücken** – eine Erklärung ohne Fundstelle muss begründen, warum es keine gibt. Damit ist das Fehlen eine bewusste Aussage und keine Nachlässigkeit. 5. **Form** – Länge, Sprache, keine leeren Felder, keine Platzhalter. 6. **Typen** – jede Angabe einer Fundstelle ist eine Zeichenkette. Diese Prüfung muss mindestens so streng sein wie der Lader in der Anwendung (`app/src/main/erklaerungen.ts`). War sie es einmal nicht: 450 Angaben standen als Zahl in der Datei, kamen hier durch und liessen die gepackte Anwendung ohne eine einzige Erklärung starten. Was „geprüft" heißt ------------------- Existenz der Norm plus Nachweis jeder angegebenen Feinstelle (Absatz, Nummer, Buchstabe, Anlagen-Stelle; Satz als Plausibilitätsgrenze). Die Ausgabe unterscheidet „vollständig geprüft" (jede Angabe nachgewiesen) von „nur Existenz geprüft" (mehr gab die Angabe maschinell nicht her). Ein einziger Zähler hatte beides vermengt: Er zählte schon nach der Existenzprüfung, und zwei frühe Rückkehrpfade ließen Nummern ohne Absatzangabe sowie fast alle Satzangaben ungeprüft passieren – als „geprüft" ausgewiesen waren sie trotzdem. Was diese Prüfung NICHT leisten kann ------------------------------------ Ob die zitierte Norm die Aussage inhaltlich trägt. Eine Erklärung kann auf einen existierenden Paragrafen verweisen und trotzdem falsch sein. Dafür gibt es die redaktionelle Durchsicht; diese Prüfung schließt nur die Fehlerklasse aus, die sich maschinell sicher ausschließen lässt. Aufruf ------ python data-pipeline/pruefe_erklaerungen.py # Bestand prüfen python data-pipeline/pruefe_erklaerungen.py --selbsttest # Prüfung prüfen """ from __future__ import annotations import io import json import re import sys from dataclasses import dataclass from pathlib import Path WURZEL = Path(__file__).resolve().parent.parent KATALOG = WURZEL / 'content' / 'katalog' / 'katalog.json' GESETZE = WURZEL / 'content' / 'gesetze' / 'index.json' ERKLAERUNGEN = WURZEL / 'content' / 'erklaerungen.json' MIN_KURZ, MAX_KURZ = 30, 400 #: Laengengrenzen der ausfuehrlichen Begruendung. #: #: Die Obergrenze fangt Wildwuchs ab, nicht Gruendlichkeit. Gemessen an den #: ersten 193 Erklaerungen: Median 1194 Zeichen, 90 Prozent unter 1634. Ueber #: 2500 lag genau eine - die Frage nach der Beschusspflicht, die vier #: Waffenarten einzeln durchgeht und fuer jede eine eigene Ausnahme nennt. #: Solche Faelle gibt es, und sie zu verstuemmeln waere der schlechtere Weg. MIN_TEXT, MAX_TEXT = 80, 3000 MAX_MERKSATZ = 300 MIN_GRUND = 25 #: Wörter, die auf einen unfertigen Text hindeuten. PLATZHALTER = re.compile( r'\b(TODO|TBD|FIXME|Platzhalter|Lorem ipsum|XXX|\.\.\.\s*$)', re.IGNORECASE ) #: Hoechstzahl der Kernpunkte. Mehr waere keine Pruefliste mehr, sondern eine #: zweite Musterantwort. MAX_KERNPUNKTE = 8 #: Ein Kernpunkt ist ein Stichwort, kein Satzgefuege. MAX_KERNPUNKT_LAENGE = 140 #: Die eine Formulierung, die hier nie stehen darf. Bis Fassung 0.17.0 stand #: ueber einer aehnlichen Liste "Diese Kernelemente muss Ihre Antwort #: enthalten" - unbelegt, und ausdruecklich abgeschafft. Die Pruefung haelt #: fest, dass sie nicht durch die Hintertuer zurueckkommt. PFLICHTBEHAUPTUNG = re.compile(r'muss\s+(ihre|die)\s+antwort|zwingend\s+erforderlich', re.I) #: Erlaubte Formen einer Normbezeichnung. #: #: Anlagen werden nicht einheitlich bezeichnet: WaffG und 1. SprengV zaehlen #: arabisch ("Anlage 1"), BeschussV und SprengG roemisch ("Anlage II"), die #: AWaffV hat genau eine und nennt sie nur "Anlage". NORM_MUSTER = re.compile(r'^(§ \d+[a-z]?|Anlage( [0-9IVX]+)?)$') #: Eine Nummer ist eine Zahl, ggf. mit Buchstabenzusatz („3", „3a"). NUMMER_MUSTER = re.compile(r'^\d+[a-z]?$') #: Gliederungsmarken innerhalb einer Anlage: „Abschnitt 1", „Nr. 1.1", #: „Abbildung 12". Alles Weitere in einer Stellenangabe ist Beschreibung und #: wird nicht geprueft - sie stammt vom Verfasser, nicht aus dem Gesetz. GLIEDERUNGSMARKE = re.compile( r'(Abschnitt|Unterabschnitt|Abbildung|Anhang|Kategorie|Tabelle|Teil|Nummer|Nrn?\.?)' r'\s+([0-9IVX][0-9IVXa-z.]*?)(?=[\s,;:)]|$)', re.IGNORECASE, ) #: Aufzählungsmarken am Zeilenanfang eines Normtexts: „1." bzw. „a)". Der #: Punkt darf keine Ziffer nach sich haben, sonst träfe „1." auch „1.1". NORMTEXT_NUMMER = re.compile(r'^(\d+[a-z]?)\.(?!\d)') NORMTEXT_BUCHSTABE = re.compile(r'^([a-z]{1,2})\)') def norm_gliederung(text: str) -> dict[str, list[str]]: """Liest die Aufzählungsstruktur aus dem Text einer ungegliederten Norm. Der Index legt Gliederung nur je Absatz ab. Beginnt eine Norm ohne „(1)"-Marke, hat sie im Index keine Absätze; 251 Paragrafen des Index sind das. Zitiert werden davon mit Nummer oder Buchstabe genau vier: § 8 und § 37a WaffG, § 29 SprengG, § 34 AWaffV. Ihre Nummern und Buchstaben stehen zeilenweise im Normtext, denn der Indexer beginnt für jede `
`-Marke eine Zeile. Buchstaben gehören zur zuletzt begonnenen Nummer; steht keine voran, zählt die Norm unmittelbar mit Buchstaben auf (dieselbe Zuordnung wie falten() in data-pipeline/gesetze_index.py). """ gliederung: dict[str, list[str]] = {} letzte_nummer: str | None = None for zeile in text.split('\n'): treffer = NORMTEXT_NUMMER.match(zeile) if treffer: letzte_nummer = treffer.group(1) gliederung.setdefault(letzte_nummer, []) continue treffer = NORMTEXT_BUCHSTABE.match(zeile) if treffer: if letzte_nummer is None: gliederung.setdefault(treffer.group(1), []) else: gliederung[letzte_nummer].append(treffer.group(1)) return gliederung @dataclass class Befund: frage_id: str feld: str meldung: str def __str__(self) -> str: return f'{self.frage_id:12} {self.feld:16} {self.meldung}' class Pruefung: def __init__(self, katalog: dict, gesetze: dict) -> None: self.fragen = {f['id']: f for f in katalog['fragen']} self.gesetze = gesetze['gesetze'] self.befunde: list[Befund] = [] #: Stellenangaben ohne erkennbare Gliederungsmarke. Kein Fehler, aber #: maschinell nicht nachprüfbar – das soll sichtbar bleiben. self.ungeprueft: list[str] = [] #: Zählt, wie oft eine Norm zitiert wird – als Anhaltspunkt für die #: redaktionelle Durchsicht, nicht als Prüfkriterium. self.normnutzung: dict[str, int] = {} #: Ehrliche Zählung statt eines Sammelzählers: „voll_geprueft" heißt, #: jede Angabe der Fundstelle wurde gegen den Gesetzestext gehalten; #: „nur_existenz" heißt, mehr als das Vorhandensein der Norm gab die #: Angabe maschinell nicht her. Der frühere Sammelzähler wies beides #: als „geprüft" aus – 188 der 1843 Fundstellen (Satzangaben, #: Nummern ohne Absatz, eine Anlage ohne Stelle) waren es nur halb. self.voll_geprueft = 0 self.nur_existenz = 0 #: Querverweise je Frage, fuer den Abgleich auf Beidseitigkeit. self.verweise: dict[str, set[str]] = {} def melden(self, frage_id: str, feld: str, meldung: str) -> None: self.befunde.append(Befund(frage_id, feld, meldung)) # ── Fundstellen ────────────────────────────────────────────────────── def fundstelle_pruefen(self, frage_id: str, index: int, fundstelle: dict) -> None: ort = f'fundstelle[{index}]' # Der Lader in der Anwendung prueft die Typen. Ist diese Pruefung hier # laxer, geht etwas durch, das erst beim Nutzer scheitert - genau das # ist passiert: 450 Angaben standen als Zahl statt als Zeichenkette in # der Datei, kamen durch die Pruefung und liessen die gepackte # Anwendung ohne eine einzige Erklaerung starten. for feld in ('gesetz', 'norm', 'absatz', 'nummer', 'buchstabe', 'satz', 'stelle'): wert = fundstelle.get(feld) if wert is not None and not isinstance(wert, str): self.melden( frage_id, ort, f'„{feld}" muss eine Zeichenkette sein, ist {type(wert).__name__}: {wert!r}', ) return kuerzel = fundstelle.get('gesetz') if kuerzel not in self.gesetze: self.melden(frage_id, ort, f'Unbekanntes Gesetz: {kuerzel!r}') return gesetz = self.gesetze[kuerzel] norm_name = fundstelle.get('norm', '') if not NORM_MUSTER.match(str(norm_name)): self.melden( frage_id, ort, f'Norm muss „§ 12" oder „Anlage 1" lauten, ist: {norm_name!r}' ) return norm = gesetz['normen'].get(norm_name) if norm is None: self.melden(frage_id, ort, f'{norm_name} gibt es im {kuerzel} nicht') return self.normnutzung[f'{kuerzel} {norm_name}'] = ( self.normnutzung.get(f'{kuerzel} {norm_name}', 0) + 1 ) # Ab hier ist die Existenz der Norm nachgewiesen. Ob auch jede # Feinangabe nachgeprüft werden konnte, entscheiden die beiden Zweige – # der Zähler übernimmt deren Antwort, statt vorab „geprüft" zu melden. if norm['ist_anlage']: vollstaendig = self.anlage_pruefen(frage_id, ort, kuerzel, norm_name, norm, fundstelle) else: vollstaendig = self.paragraf_pruefen( frage_id, ort, kuerzel, norm_name, norm, fundstelle ) if vollstaendig: self.voll_geprueft += 1 else: self.nur_existenz += 1 def paragraf_pruefen( self, frage_id: str, ort: str, kuerzel: str, norm_name: str, norm: dict, fundstelle: dict, ) -> bool: """Prüft die Feinstellen eines Paragrafenzitats. True heißt: Jede Angabe der Fundstelle wurde gegen den Gesetzestext gehalten (Beanstandungen eingeschlossen). False heißt: Über die Existenz der Norm hinaus war nichts nachprüfbar. """ if fundstelle.get('stelle') is not None: self.melden(frage_id, ort, '„stelle" ist nur bei Anlagen zulässig') absatz_name = fundstelle.get('absatz') if absatz_name is None: # Zulässig: Ein Paragraf ohne Absatzgliederung wird als Ganzes # zitiert. Bei gegliederten Normen ist das aber ungenau – und # eine Nummer ließe sich keinem Absatz zuordnen. if len(norm['absaetze']) > 1: self.melden( frage_id, ort, f'{norm_name} {kuerzel} hat {len(norm["absaetze"])} Absaetze – ' 'bitte den zutreffenden angeben', ) return False if norm['absaetze']: # Genau ein Absatz: Jede Feinangabe kann sich nur auf ihn # beziehen, also wird gegen ihn geprüft. absatz = next(iter(norm['absaetze'].values())) else: # Norm ohne Absatzgliederung: Nummern und Buchstaben stehen # nur im Normtext. Vorher kehrte die Prüfung hier um; die # zwölf Feinangaben ohne Absatz blieben ungeprüft, davon acht # Nummernangaben zu § 37a WaffG. absatz = {'gliederung': norm_gliederung(norm['text']), 'text': norm['text']} self.gliederung_pruefen(frage_id, ort, kuerzel, norm_name, None, absatz, fundstelle) return True absatz = norm['absaetze'].get(str(absatz_name)) if absatz is None: vorhanden = ', '.join(norm['absaetze'].keys()) or 'keine' self.melden( frage_id, ort, f'{norm_name} {kuerzel} hat keinen Abs. {absatz_name} (vorhanden: {vorhanden})', ) return False self.gliederung_pruefen(frage_id, ort, kuerzel, norm_name, absatz_name, absatz, fundstelle) return True def gliederung_pruefen( self, frage_id: str, ort: str, kuerzel: str, norm_name: str, absatz_name: str | None, absatz: dict, fundstelle: dict, ) -> None: if absatz_name is None: # Ohne Absatzangabe zitiert – die Norm selbst ist die Stelle. stelle = f'{norm_name} {kuerzel}' else: stelle = f'{norm_name} Abs. {absatz_name} {kuerzel}' gliederung: dict[str, list[str]] = absatz['gliederung'] # Der Satz zuerst: Er ist von Nummer und Buchstabe unabhängig. Als er # hinter deren Frührückkehren stand, wurde er nur erreicht, wenn # Nummer UND Buchstabe angegeben waren – 183 von 184 Satzangaben # gingen ungeprüft durch. satz = fundstelle.get('satz') if satz is not None: # Sätze werden im XML nicht ausgezeichnet. Geprüft wird deshalb # nur, dass der Text überhaupt so viele Sätze haben kann. saetze = absatz['text'].count('.') + absatz['text'].count(';') if not str(satz).isdigit() or int(satz) < 1: self.melden(frage_id, ort, f'Satz muss eine Zahl ab 1 sein, ist: {satz!r}') elif int(satz) > max(1, saetze): self.melden(frage_id, ort, f'{stelle} hat vermutlich keinen Satz {satz}') nummer = fundstelle.get('nummer') if nummer is not None: # Ein blosser Buchstabe ist keine Nummer. Ohne diese Regel wuerde # eine Fundstelle „Nr. a" durchgehen, sobald der Absatz # unmittelbar mit Buchstaben aufzaehlt. if not NUMMER_MUSTER.match(str(nummer)): self.melden( frage_id, ort, f'„nummer" muss eine Zahl sein, ist: {nummer!r} – ' 'Buchstaben gehoeren nach „buchstabe"', ) return if not gliederung: self.melden(frage_id, ort, f'{stelle} ist nicht in Nummern gegliedert') return if str(nummer) not in gliederung: vorhanden = ', '.join(gliederung.keys()) self.melden( frage_id, ort, f'{stelle} hat keine Nr. {nummer} (vorhanden: {vorhanden})' ) return buchstabe = fundstelle.get('buchstabe') if buchstabe is None: return if nummer is None: # Zulaessig, wenn der Absatz selbst mit Buchstaben aufzaehlt – # etwa § 3a Abs. 2 SprengG. if str(buchstabe) in gliederung: return self.melden( frage_id, ort, f'{stelle} zaehlt nicht unmittelbar mit Buchstaben auf – ' 'bitte zusaetzlich die Nummer angeben', ) return buchstaben = gliederung.get(str(nummer), []) if not buchstaben: self.melden( frage_id, ort, f'{stelle} Nr. {nummer} ist nicht in Buchstaben gegliedert' ) return if str(buchstabe) not in buchstaben: self.melden( frage_id, ort, f'{stelle} Nr. {nummer} hat keinen Buchst. {buchstabe} ' f'(vorhanden: {", ".join(buchstaben)})', ) def anlage_pruefen( self, frage_id: str, ort: str, kuerzel: str, norm_name: str, norm: dict, fundstelle: dict, ) -> bool: """Prüft die Stellenangabe eines Anlagenzitats. True heißt: Die Gliederungsmarken der Stelle wurden im Anlagentext nachgewiesen (Beanstandungen eingeschlossen). False heißt: Über die Existenz der Anlage hinaus war nichts nachprüfbar. """ # „satz" steht mit in der Liste: Anlagen kennen keine Satzzählung, # und eine stumm ignorierte Angabe sähe wie eine geprüfte aus. for verboten in ('absatz', 'nummer', 'buchstabe', 'satz'): if fundstelle.get(verboten) is not None: self.melden( frage_id, ort, f'„{verboten}" ist bei {norm_name} nicht zulässig – bitte „stelle" verwenden', ) stelle = fundstelle.get('stelle') if stelle is None: # Anlagen sind seitenlang; ohne Stellenangabe wäre nur die # Existenz der Anlage nachgewiesen und der Leser suchte allein. self.melden( frage_id, ort, f'{norm_name} {kuerzel} ohne „stelle" – so ist nur die Existenz ' 'der Anlage nachgewiesen, bitte die Stelle angeben', ) return False # Zeilenumbrüche stören den Vergleich; die Anlagen sind zeilenweise # gesetzt, eine Angabe wie „Abbildung 1" aber steht als Wortfolge da. text = ' '.join(norm['text'].split()) marken = GLIEDERUNGSMARKE.findall(str(stelle)) if not marken: # Keine Gliederungsmarke erkennbar – dann ist die Angabe reine # Beschreibung und maschinell nicht prüfbar. Das ist kein Fehler, # aber es soll sichtbar bleiben. self.ungeprueft.append(f'{frage_id} {ort}: „{stelle}"') return False for wort, zahl in marken: if wort.lower().startswith(('nr', 'nummer')): # Anlagen listen ihre Nummern ohne das Wort „Nr.". # Listennummern stehen als „12." – der Punkt gehoert zur Marke. # „1" darf aber nicht in „1.1" treffen: deshalb nicht vor einer # Ziffer, auch nicht ueber einen Punkt hinweg. gefunden = ( re.search(rf'(? bool: if not isinstance(wert, str) or not wert.strip(): self.melden(frage_id, feld, 'fehlt oder ist leer') return False text = wert.strip() if len(text) < unten: self.melden(frage_id, feld, f'zu kurz ({len(text)} Zeichen, mindestens {unten})') return False if len(text) > oben: self.melden(frage_id, feld, f'zu lang ({len(text)} Zeichen, höchstens {oben})') return False if PLATZHALTER.search(text): self.melden(frage_id, feld, 'enthält einen Platzhalter') return False if text != wert: self.melden(frage_id, feld, 'beginnt oder endet mit Leerraum') return False return True # ── Antwortmöglichkeiten ───────────────────────────────────────────── #: Wörter, die für sich genommen eine Antwortmöglichkeit sind: „Nein", #: „keine", „Ja, wenn …". Sie tragen keinen eigenen Sachbegriff, an dem #: sich nachweisen ließe, dass die Erklärung sie behandelt. #: #: Eine Möglichkeit, die aus nichts anderem besteht – das blanke „Nein" #: neben zwei Sachantworten –, ist deshalb immer als behandelt zu werten: #: Sie ist die Verneinung der ganzen Frage, und die Erklärung beantwortet #: sie, indem sie sagt, was gilt. Ein eigener Satz „Antwort c ist falsch" #: brächte dort nichts, was nicht schon dastünde. ANTWORTWOERTER = ('nein', 'ja', 'keine', 'keiner', 'keines', 'alle', 'doch', 'nichts') #: Zu kurz oder zu allgemein, um eine Option von ihren Geschwistern zu #: unterscheiden. Ohne diese Liste zählte „Schusswaffe" als Nachweis, #: obwohl es in jeder Option derselben Frage steht. OHNE_UNTERSCHEIDUNGSKRAFT = frozenset( '''schusswaffe schusswaffen waffe waffen munition person personen jahren jahre gemaess waffenrechtlich waffenrechtliche waffenrechtlichen sinne gegenstand gegenstaende moeglich moeglichkeit erlaubt verboten zulaessig gestattet'''.split() ) @staticmethod def _falte(text: str) -> str: """Kleinschreibung ohne Umlaute – damit „Armbrust" auf „Armbrüste" trifft.""" tief = text.lower() for von, nach in (('ä', 'ae'), ('ö', 'oe'), ('ü', 'ue'), ('ß', 'ss')): tief = tief.replace(von, nach) return tief @staticmethod def _buchstabenbezug(text: str) -> set: """Welche Optionsbuchstaben nennt die Erklärung ausdrücklich? Der Hausstil kennt mehrere Formen, und genau daran ist die ursprüngliche Messung gescheitert: Wer nur nach „a)" sucht, hält „das macht Antwort b falsch" für keine Nennung. """ gefunden = set() for muster in ( r'(?:^|[^A-Za-zÄÖÜäöü])([a-f])\)', r'„([a-f])“', r'\bAntwort(?:möglichkeit)?\s+([a-f])\b', r'\bZu\s+([a-f])\b', r'\bBuchstabe\s+([a-f])\b', ): gefunden |= set(re.findall(muster, text)) return gefunden def optionen_pruefen(self, frage_id: str, erklaerung: dict) -> None: """Behandelt die Erklärung jede Antwortmöglichkeit? **Warum das hier steht.** Eine Erklärung, die eine Antwortmöglichkeit übergeht, lässt den Lernenden mit der Frage allein, die ihn am meisten beschäftigt: Warum ist gerade die, die ich angekreuzt habe, falsch? Am 01.09.2026 traf das auf **keine einzige** der 471 Auswahlfragen zu – erst durch diese Prüfung bleibt das so. **Warum die Prüfung so großzügig ist.** Der Hausstil benennt Antwortmöglichkeiten auf zwei Wegen: mit ihrem Buchstaben (130 von 471) oder mit ihrem Gegenstand – „die Armbrust scheitert am Lauf". Der zweite Weg ist der bessere: Wer sich die Erklärung vorlesen lässt, hört bei „zu a)" nichts, woran er sich erinnern könnte. Diese Prüfung erkennt deshalb beide und fällt nur dann aus, wenn eine Möglichkeit auf **keinem** der beiden Wege vorkommt. """ frage = self.fragen.get(frage_id) or {} optionen = frage.get('optionen') or [] if not optionen: return text = f"{erklaerung.get('kurz') or ''} {erklaerung.get('text') or ''}" gefaltet = self._falte(text) briefe = self._buchstabenbezug(text) kurz_anfang = self._falte((erklaerung.get('kurz') or '')[:40]) # Wörter, die in allen Möglichkeiten vorkommen, unterscheiden nichts. woerter_je_option = [] for option in optionen: roh = (option.get('inhalt') or {}).get('text') or '' woerter_je_option.append( {w for w in re.findall(r'[a-z]{4,}', self._falte(roh))} - self.OHNE_UNTERSCHEIDUNGSKRAFT - set(self.ANTWORTWOERTER) ) gemeinsam = set.intersection(*woerter_je_option) if len(woerter_je_option) > 1 else set() for option, woerter in zip(optionen, woerter_je_option): marke = option.get('label') or '?' if marke in briefe: continue roh = (option.get('inhalt') or {}).get('text') or '' wortmenge = set(re.findall(r'[a-z]+', self._falte(roh))) if any(w in self.ANTWORTWOERTER and w in kurz_anfang for w in wortmenge): continue eigen = woerter - gemeinsam if not eigen: continue # Wortstamm statt Wortgleichheit: „Leuchtspurpatronen" gilt als # behandelt, wenn irgendwo „Leuchtspurmunition" steht. if any(wort[: max(4, min(5, len(wort)))] in gefaltet for wort in eigen): continue self.melden( frage_id, f'option[{marke}]', 'kommt in der Erklärung weder mit ihrem Buchstaben noch mit ihrem ' f'Gegenstand vor: „{roh[:70]}"', ) def erklaerung_pruefen(self, frage_id: str, erklaerung: dict) -> None: if frage_id not in self.fragen: self.melden(frage_id, 'zuordnung', 'Diese Frage gibt es im Katalog nicht') return erlaubt = { 'kurz', 'text', 'fundstellen', 'ohneFundstelleGrund', 'merksatz', 'verwandt', 'kernpunkte', } for feld in set(erklaerung) - erlaubt: self.melden(frage_id, feld, 'Unbekanntes Feld') self.optionen_pruefen(frage_id, erklaerung) self.verwandt_pruefen(frage_id, erklaerung.get('verwandt')) self.kernpunkte_pruefen(frage_id, erklaerung.get('kernpunkte')) self.text_pruefen(frage_id, 'kurz', erklaerung.get('kurz'), MIN_KURZ, MAX_KURZ) self.text_pruefen(frage_id, 'text', erklaerung.get('text'), MIN_TEXT, MAX_TEXT) if 'merksatz' in erklaerung: self.text_pruefen(frage_id, 'merksatz', erklaerung['merksatz'], 10, MAX_MERKSATZ) fundstellen = erklaerung.get('fundstellen') if not isinstance(fundstellen, list): self.melden(frage_id, 'fundstellen', 'fehlt oder ist keine Liste') return for index, fundstelle in enumerate(fundstellen): if not isinstance(fundstelle, dict): self.melden(frage_id, f'fundstelle[{index}]', 'ist kein Objekt') continue self.fundstelle_pruefen(frage_id, index, fundstelle) grund = erklaerung.get('ohneFundstelleGrund') if not fundstellen: if grund is None: self.melden( frage_id, 'fundstellen', 'Ohne Fundstelle muss „ohneFundstelleGrund" gesetzt sein', ) else: self.text_pruefen(frage_id, 'ohneFundstelleGrund', grund, MIN_GRUND, 400) elif grund is not None: self.melden( frage_id, 'ohneFundstelleGrund', 'gesetzt, obwohl Fundstellen vorhanden sind', ) def kernpunkte_pruefen(self, frage_id: str, kernpunkte: object) -> None: """Prüft die Kernpunkte einer offenen Frage. Die Punkte sind eine Einschaetzung dieser Software, keine Aussage ueber den amtlichen Katalog. Geprueft wird deshalb nur, was sich maschinell sicher pruefen laesst - und zwei Dinge, die inhaltlich zaehlen: Sie gehoeren ausschliesslich an offene Fragen (bei einer Auswahlfrage stehen die Antworten schon da), und sie duerfen nirgends behaupten, was eine Antwort enthalten *muss*. Genau diese Behauptung hat `docs/entscheidung-freitext.md` Abschnitt 8 ausgeschlossen. """ if kernpunkte is None: return frage = self.fragen.get(frage_id) if frage is not None and frage['typ'] == 'mc': self.melden( frage_id, 'kernpunkte', 'nur bei offenen Fragen zulaessig - bei Auswahlfragen stehen die Antworten da', ) return if not isinstance(kernpunkte, list): self.melden(frage_id, 'kernpunkte', 'ist keine Liste') return if len(kernpunkte) < 2: # Eine Pruefliste mit einem Punkt ist kein Werkzeug, sondern Beiwerk. self.melden(frage_id, 'kernpunkte', 'braucht mindestens zwei Punkte') return if len(kernpunkte) > MAX_KERNPUNKTE: self.melden( frage_id, 'kernpunkte', f'mehr als {MAX_KERNPUNKTE} Punkte sind keine Liste mehr' ) gesehen: set[str] = set() for punkt in kernpunkte: if not isinstance(punkt, str): self.melden(frage_id, 'kernpunkte', f'{punkt!r} ist keine Zeichenkette') continue if punkt in gesehen: self.melden(frage_id, 'kernpunkte', f'„{punkt}" steht doppelt') gesehen.add(punkt) if not punkt.strip(): self.melden(frage_id, 'kernpunkte', 'leerer Punkt') elif len(punkt) > MAX_KERNPUNKT_LAENGE: self.melden( frage_id, 'kernpunkte', f'zu lang ({len(punkt)} Zeichen, hoechstens {MAX_KERNPUNKT_LAENGE})', ) if PFLICHTBEHAUPTUNG.search(punkt): self.melden( frage_id, 'kernpunkte', 'behauptet, was eine Antwort enthalten muss - das ist unbelegt ' '(entscheidung-freitext.md Abschnitt 8)', ) def verwandt_pruefen(self, frage_id: str, verwandt: object) -> None: """Prüft die Querverweise einer Erklärung. Der Katalog kennt keine Frage-zu-Frage-Beziehung; ``verwandt`` ist eine redaktionelle Zuordnung dieser Software. Geprüft wird, was sich maschinell sicher prüfen lässt: Existenz, kein Selbstbezug, keine Wiederholung. Ob zwei Fragen wirklich zusammengehören, entscheidet die Redaktion – die Beidseitigkeit prüft :meth:`verweise_abgleichen`, wenn alle Erklärungen gelesen sind. """ if verwandt is None: return if not isinstance(verwandt, list): self.melden(frage_id, 'verwandt', 'ist keine Liste') return if not verwandt: # Eine leere Liste ist eine Angabe, die nichts angibt. self.melden(frage_id, 'verwandt', 'ist leer; dann gehoert das Feld weg') return gesehen: set[str] = set() for eintrag in verwandt: if not isinstance(eintrag, str): self.melden(frage_id, 'verwandt', f'{eintrag!r} ist keine Zeichenkette') continue if eintrag == frage_id: self.melden(frage_id, 'verwandt', 'verweist auf sich selbst') continue if eintrag in gesehen: self.melden(frage_id, 'verwandt', f'{eintrag} steht doppelt') continue gesehen.add(eintrag) if eintrag not in self.fragen: self.melden(frage_id, 'verwandt', f'{eintrag} gibt es im Katalog nicht') else: self.verweise.setdefault(frage_id, set()).add(eintrag) def verweise_abgleichen(self) -> None: """Verlangt, dass jeder Querverweis beidseitig steht. Ein einseitiger Verweis ist fast immer ein Versehen beim Schreiben – und er zeigt die Verbindung nur dem, der zufaellig von der richtigen Seite kommt. Wer II-34 loest, soll von II-36 erfahren; wer mit II-36 anfaengt, ebenso. """ for frage_id, ziele in sorted(self.verweise.items()): for ziel in sorted(ziele): if frage_id not in self.verweise.get(ziel, set()): self.melden( frage_id, 'verwandt', f'{ziel} wird genannt, nennt aber {frage_id} nicht zurueck', ) def alles_pruefen(self, daten: dict) -> None: meta = daten.get('meta') if not isinstance(meta, dict): self.melden('(meta)', 'meta', 'fehlt') else: for pflicht in ('version', 'stand', 'gesetzesstand', 'hinweis'): if pflicht not in meta: self.melden('(meta)', pflicht, 'fehlt') # Der vermerkte Gesetzesstand muss zum Index passen. Sonst wären # die Erklärungen gegen etwas anderes geprüft worden, als # ausgewiesen ist. for kuerzel, stand in (meta.get('gesetzesstand') or {}).items(): if kuerzel not in self.gesetze: self.melden('(meta)', 'gesetzesstand', f'Unbekanntes Gesetz: {kuerzel}') elif self.gesetze[kuerzel]['stand'] != stand: self.melden( '(meta)', 'gesetzesstand', f'{kuerzel}: vermerkt „{stand}", Index sagt ' f'„{self.gesetze[kuerzel]["stand"]}"', ) zu_frage = daten.get('zuFrage') if not isinstance(zu_frage, dict): self.melden('(datei)', 'zuFrage', 'fehlt oder ist kein Objekt') return for frage_id, erklaerung in sorted(zu_frage.items()): if not isinstance(erklaerung, dict): self.melden(frage_id, '(ganz)', 'ist kein Objekt') continue self.erklaerung_pruefen(frage_id, erklaerung) # Erst wenn alle gelesen sind: Ein Verweis auf eine spaeter stehende # Frage waere sonst als einseitig gemeldet worden. self.verweise_abgleichen() #: Bewusst fehlerhafte Erklärungen. Jede muss beanstandet werden – sonst ist #: die Prüfung nutzlos, ohne dass es auffiele. SELBSTTEST: list[tuple[str, dict, str]] = [ ( 'erfundener Paragraf', {'fundstellen': [{'gesetz': 'WaffG', 'norm': '§ 999'}]}, 'gibt es im WaffG nicht', ), ( 'erfundener Absatz', {'fundstellen': [{'gesetz': 'StGB', 'norm': '§ 32', 'absatz': '7'}]}, 'keinen Abs. 7', ), ( 'erfundene Nummer', {'fundstellen': [{'gesetz': 'WaffG', 'norm': '§ 12', 'absatz': '1', 'nummer': '99'}]}, 'keine Nr. 99', ), ( 'erfundener Buchstabe', { 'fundstellen': [ { 'gesetz': 'WaffG', 'norm': '§ 12', 'absatz': '1', 'nummer': '1', 'buchstabe': 'z', } ] }, 'keinen Buchst. z', ), ( 'Absatz als Zahl statt als Zeichenkette', {'fundstellen': [{'gesetz': 'StGB', 'norm': '§ 32', 'absatz': 1}]}, 'muss eine Zeichenkette sein', ), ( 'unbekanntes Gesetz', {'fundstellen': [{'gesetz': 'BGB', 'norm': '§ 1'}]}, 'Unbekanntes Gesetz', ), ( 'Nummer in einem ungegliederten Absatz', {'fundstellen': [{'gesetz': 'StGB', 'norm': '§ 32', 'absatz': '1', 'nummer': '1'}]}, 'nicht in Nummern gegliedert', ), ( 'Buchstabe ohne Nummer, wo der Absatz mit Nummern aufzaehlt', {'fundstellen': [{'gesetz': 'WaffG', 'norm': '§ 12', 'absatz': '1', 'buchstabe': 'a'}]}, 'nicht unmittelbar mit Buchstaben', ), ( 'Buchstabe als Nummer ausgegeben', {'fundstellen': [{'gesetz': 'SprengG', 'norm': '§ 3a', 'absatz': '2', 'nummer': 'a'}]}, 'muss eine Zahl sein', ), ( 'Absatzangabe bei einer Anlage', {'fundstellen': [{'gesetz': 'WaffG', 'norm': 'Anlage 1', 'absatz': '1'}]}, 'nicht zulässig', ), ( 'Anlage, die es nicht gibt', {'fundstellen': [{'gesetz': 'BeschussV', 'norm': 'Anlage IX'}]}, 'gibt es im BeschussV nicht', ), ( 'Stelle, die in der Anlage nicht vorkommt', { 'fundstellen': [ {'gesetz': 'WaffG', 'norm': 'Anlage 1', 'stelle': 'Abschnitt 1 Nr. 99.99'} ] }, 'kommt in Anlage 1', ), ( 'gegliederte Norm ohne Absatzangabe', {'fundstellen': [{'gesetz': 'AWaffV', 'norm': '§ 13'}]}, 'bitte den zutreffenden angeben', ), # Die folgenden Fälle liefen früher stumm durch: Der Satz-Check war nur # hinter Nummer UND Buchstabe erreichbar, Feinangaben ohne Absatz kehrten # vor jeder Gliederungsprüfung um, und eine Anlage ohne Stelle galt als # geprüft, obwohl nur ihre Existenz nachgewiesen war. ( 'erfundener Satz ohne Buchstaben-Angabe', {'fundstellen': [{'gesetz': 'StGB', 'norm': '§ 32', 'absatz': '1', 'satz': '99'}]}, 'keinen Satz 99', ), ( 'Satz, der keine Zahl ist', {'fundstellen': [{'gesetz': 'StGB', 'norm': '§ 32', 'absatz': '1', 'satz': 'zwei'}]}, 'Satz muss eine Zahl ab 1 sein', ), ( 'erfundene Nummer in einer Norm ohne Absatzgliederung', {'fundstellen': [{'gesetz': 'WaffG', 'norm': '§ 37a', 'nummer': '99'}]}, 'keine Nr. 99', ), ( 'erfundener Buchstabe in einer Norm ohne Absatzgliederung', {'fundstellen': [{'gesetz': 'WaffG', 'norm': '§ 37a', 'nummer': '3', 'buchstabe': 'z'}]}, 'keinen Buchst. z', ), ( 'Anlage ohne Stellenangabe', {'fundstellen': [{'gesetz': 'WaffG', 'norm': 'Anlage 1'}]}, 'nur die Existenz', ), ( 'Fundstelle fehlt ohne Begründung', {'fundstellen': []}, 'ohneFundstelleGrund', ), ( 'Begründung trotz vorhandener Fundstelle', { 'fundstellen': [{'gesetz': 'StGB', 'norm': '§ 32', 'absatz': '1'}], 'ohneFundstelleGrund': 'Es gibt hierzu keine gesetzliche Regelung im engeren Sinne.', }, 'obwohl Fundstellen vorhanden', ), ( 'Platzhalter im Text', { # Bewusst lang genug, damit der Fall an der Platzhalterpruefung # scheitert und nicht schon an der Mindestlaenge. 'text': ( 'TODO: Die Begruendung zu dieser Frage fehlt noch und muss vor der ' 'Auslieferung ergaenzt werden, damit hier nichts Unfertiges steht.' ) }, 'Platzhalter', ), ('unbekanntes Feld', {'quelle': 'irgendwoher'}, 'Unbekanntes Feld'), ('erfundene verwandte Frage', {'verwandt': ['XI.9-99']}, 'gibt es im Katalog nicht'), ('verwandt als Text statt Liste', {'verwandt': 'II-34'}, 'ist keine Liste'), ('leere Verwandtenliste', {'verwandt': []}, 'ist leer'), ] def selbsttest(katalog: dict, gesetze: dict) -> int: """Prüft die Prüfung. Eine Prüfung, die nichts beanstandet, ist von einer funktionierenden nicht zu unterscheiden – bis jemand sich auf sie verlässt. Deshalb wird jede Fehlerart einmal absichtlich erzeugt. """ # Eine gültige Erklärung als Ausgangspunkt; jeder Fall ändert genau eines. frage_id = katalog['fragen'][0]['id'] gueltig = { 'kurz': 'Ein Satz, der die richtige Antwort in ausreichender Laenge traegt.', 'text': ( 'Eine Begruendung von hinreichender Laenge, damit die Formpruefung sie ' 'nicht wegen Kuerze beanstandet und der Fall wirklich an der gemeinten ' 'Stelle scheitert.' ), 'fundstellen': [{'gesetz': 'StGB', 'norm': '§ 32', 'absatz': '1'}], } fehlgeschlagen = 0 # Mitgezaehlt statt am Ende gerechnet: Die Schlusszeile nannte bis Fassung # 0.24.1 `len(SELBSTTEST) + 1` und damit 26 Faelle, gefahren wurden 32 - # nach der Liste laufen sieben weitere Faelle einzeln. Wer die Zahl als # Mass der Abdeckung nahm, unterschaetzte sie um sechs; und faellt einmal # ein Fall aus der Liste heraus, sagte sie darueber gar nichts. Dasselbe # Muster wie in pruefe_glossar.py. gelaufen = 0 for name, aenderung, erwartet in SELBSTTEST: gelaufen += 1 fall = {**gueltig, **aenderung} pruefung = Pruefung(katalog, gesetze) pruefung.erklaerung_pruefen(frage_id, fall) getroffen = any(erwartet in b.meldung for b in pruefung.befunde) if getroffen: print(f' erkannt: {name}') else: gemeldet = '; '.join(b.meldung for b in pruefung.befunde) or '(nichts beanstandet)' print(f' DURCHGELASSEN: {name}') print(f' erwartet: {erwartet}') print(f' gemeldet: {gemeldet}') fehlgeschlagen += 1 # Kernpunkte gehoeren nur an offene Fragen - der Fall braucht deshalb eine # Auswahlfrage und passt nicht in die Liste oben, die gegen die erste # Frage des Katalogs laeuft. mc_id = next(f['id'] for f in katalog['fragen'] if f['typ'] == 'mc') pruefung = Pruefung(katalog, gesetze) gelaufen += 1 pruefung.erklaerung_pruefen(mc_id, {**gueltig, 'kernpunkte': ['a', 'b']}) if any('nur bei offenen Fragen' in b.meldung for b in pruefung.befunde): print(' erkannt: Kernpunkte an einer Auswahlfrage') else: print(' DURCHGELASSEN: Kernpunkte an einer Auswahlfrage') fehlgeschlagen += 1 # Und die Formulierung, die hier nie stehen darf. pruefung = Pruefung(katalog, gesetze) gelaufen += 1 pruefung.erklaerung_pruefen( frage_id, {**gueltig, 'kernpunkte': ['das muss Ihre Antwort enthalten', 'b']} ) if any('unbelegt' in b.meldung for b in pruefung.befunde): print(' erkannt: Pflichtbehauptung in einem Kernpunkt') else: print(' DURCHGELASSEN: Pflichtbehauptung in einem Kernpunkt') fehlgeschlagen += 1 # Eine Pruefliste mit einem Punkt ist kein Werkzeug. pruefung = Pruefung(katalog, gesetze) gelaufen += 1 pruefung.erklaerung_pruefen(frage_id, {**gueltig, 'kernpunkte': ['nur einer']}) if any('mindestens zwei' in b.meldung for b in pruefung.befunde): print(' erkannt: einzelner Kernpunkt') else: print(' DURCHGELASSEN: einzelner Kernpunkt') fehlgeschlagen += 1 # Der Selbstbezug braucht die eigene Kennung und laesst sich deshalb nicht # als feste Zeichenkette in die Liste oben schreiben. pruefung = Pruefung(katalog, gesetze) gelaufen += 1 pruefung.erklaerung_pruefen(frage_id, {**gueltig, 'verwandt': [frage_id]}) if any('sich selbst' in b.meldung for b in pruefung.befunde): print(' erkannt: Verweis auf sich selbst') else: print(' DURCHGELASSEN: Verweis auf sich selbst') fehlgeschlagen += 1 # Und die Beidseitigkeit: Sie braucht zwei Erklaerungen und passt deshalb # nicht in die Einzelfallliste. zweite_id = katalog['fragen'][1]['id'] pruefung = Pruefung(katalog, gesetze) gelaufen += 1 pruefung.alles_pruefen( { 'meta': {'version': 1, 'stand': '2026-01-01', 'gesetzesstand': {}, 'hinweis': 'x'}, 'zuFrage': { frage_id: {**gueltig, 'verwandt': [zweite_id]}, zweite_id: dict(gueltig), }, } ) if any('nennt aber' in b.meldung for b in pruefung.befunde): print(' erkannt: einseitiger Querverweis') else: print(' DURCHGELASSEN: einseitiger Querverweis') fehlgeschlagen += 1 # Beidseitig gesetzt darf derselbe Fall nicht beanstandet werden. pruefung = Pruefung(katalog, gesetze) gelaufen += 1 pruefung.alles_pruefen( { 'meta': {'version': 1, 'stand': '2026-01-01', 'gesetzesstand': {}, 'hinweis': 'x'}, 'zuFrage': { frage_id: {**gueltig, 'verwandt': [zweite_id]}, zweite_id: {**gueltig, 'verwandt': [frage_id]}, }, } ) if any('verwandt' == b.feld for b in pruefung.befunde): print(' FALSCHER ALARM bei beidseitigem Querverweis') fehlgeschlagen += 1 else: print(' ok: beidseitiger Querverweis bleibt unbeanstandet') # Und die gueltige Erklaerung darf nicht beanstandet werden. pruefung = Pruefung(katalog, gesetze) gelaufen += 1 pruefung.erklaerung_pruefen(frage_id, gueltig) if pruefung.befunde: print(' FALSCHER ALARM bei einer gueltigen Erklaerung:') for befund in pruefung.befunde: print(f' {befund}') fehlgeschlagen += 1 else: print(' erkannt: gueltige Erklaerung bleibt unbeanstandet') if fehlgeschlagen: print(f'\nSelbsttest fehlgeschlagen: {fehlgeschlagen} Faelle.') return 1 print(f'\nSelbsttest bestanden: {gelaufen} Faelle.') return 0 def main() -> int: katalog_test = '--selbsttest' in sys.argv if katalog_test: katalog = json.load(io.open(KATALOG, encoding='utf-8')) gesetze = json.load(io.open(GESETZE, encoding='utf-8')) return selbsttest(katalog, gesetze) if not ERKLAERUNGEN.exists(): print(f'Keine Erklärungen gefunden: {ERKLAERUNGEN.relative_to(WURZEL)}') print('Das ist zulässig – die Anwendung läuft auch ohne.') return 0 katalog = json.load(io.open(KATALOG, encoding='utf-8')) gesetze = json.load(io.open(GESETZE, encoding='utf-8')) daten = json.load(io.open(ERKLAERUNGEN, encoding='utf-8')) pruefung = Pruefung(katalog, gesetze) pruefung.alles_pruefen(daten) zu_frage = daten.get('zuFrage') or {} gesamt = len(katalog['fragen']) abgedeckt = len([k for k in zu_frage if k in pruefung.fragen]) mit_fundstelle = len([e for e in zu_frage.values() if (e or {}).get('fundstellen')]) print(f'Fragen im Katalog: {gesamt}') print(f'davon mit Erklärung: {abgedeckt} ({abgedeckt / gesamt * 100:.1f} %)') print(f'davon mit Fundstelle: {mit_fundstelle}') # Zwei Zahlen statt einer: Ein Sammelzähler hatte 188 nur zur Hälfte # geprüfte Fundstellen als „geprüft" ausgewiesen. print(f'geprüfte Fundstellen: {pruefung.voll_geprueft + pruefung.nur_existenz}') print(f' vollständig geprüft: {pruefung.voll_geprueft} (jede Angabe nachgewiesen)') print( f' nur Existenz geprüft: {pruefung.nur_existenz}' ' (Feinangabe maschinell nicht prüfbar)' ) print(f'zitierte Normen: {len(pruefung.normnutzung)}') if pruefung.ungeprueft: print(f'\nNicht maschinell prüfbar ({len(pruefung.ungeprueft)}):') for eintrag in pruefung.ungeprueft[:20]: print(f' {eintrag}') if len(pruefung.ungeprueft) > 20: print(f' … und {len(pruefung.ungeprueft) - 20} weitere') if pruefung.befunde: print(f'\n{len(pruefung.befunde)} Beanstandungen:\n') for befund in pruefung.befunde[:200]: print(f' {befund}') if len(pruefung.befunde) > 200: print(f' … und {len(pruefung.befunde) - 200} weitere') return 1 print('\nAlle Fundstellen im amtlichen Gesetzestext nachgewiesen.') return 0 if __name__ == '__main__': sys.stdout.reconfigure(encoding='utf-8', errors='replace') # type: ignore[union-attr] raise SystemExit(main())