"""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())