"""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
#: Ein Buchstabe, der zu einer Vorschrift gehört, nicht zu einer Antwort:
#: „§ 12 Abs. 1 Nr. 3 Buchstabe d WaffG", „(§ 38 Abs. 1 Nr. 1 Buchst. e)".
#: Wird vor der Suche nach Optionsbuchstaben entfernt – siehe
#: :meth:`_buchstabenbezug`.
ZITATBUCHSTABE = re.compile(r'\bBuchst(?:\.|abe[ns]?)?\s+[a-f]\b')
@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.
**Was hier nicht mitzählt.** Buchstaben aus Gesetzeszitaten. Bis
Fassung 0.27.2 stand in der Liste unten ein Muster für „Buchstabe a",
und „Buchst. e)" fiel zusätzlich unter das Muster für „a)". Beides
meint eine Buchstabengliederung im Gesetz und nie eine
Antwortmöglichkeit: Nachgemessen kommen im Bestand 123 solche
Wendungen vor, und **keine einzige** bezeichnet eine Antwort — dafür
schreibt der Hausstil „Antwort a", „a)", „„a"" oder „Zu a".
Die Wirkung war keine Falschmeldung, sondern eine ausbleibende: Der
Buchstabenzweig ist der erste und stärkste Ausgang von
:meth:`optionen_pruefen`; ein Zitatbuchstabe konnte eine
Antwortmöglichkeit als behandelt ausweisen, die im Text gar nicht
vorkam. Gemessen galten dadurch 130 statt 121 Erklärungen als „nennt
alle Optionsbuchstaben" — neun davon allein wegen eines Zitats.
"""
ohne_zitat = Pruefung.ZITATBUCHSTABE.sub(' ', text)
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',
):
gefunden |= set(re.findall(muster, ohne_zitat))
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 (121 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 handbuchzahlen_pruefen(fehlerarten: int, faelle: int) -> int:
"""Die Zahlen im Handbuch und in der CI-Datei gegen den gefahrenen Lauf.
Das Handbuch nennt die Zahl der Fehlerarten als Mass der Abdeckung dieser
Pruefung. Sie stand dort seit Fassung 0.21.1 unveraendert auf 22 - damals
die Laenge von :data:`SELBSTTEST` -, waehrend der Lauf inzwischen 30
Fehlerarten und zwei Gegenproben fuhr. Wer die Deckung an der Zahl ablas,
unterschaetzte sie um acht.
Genau dieser Fehler war im Zaehler dieser Datei schon einmal behoben - der
Kommentar bei ``gelaufen`` haelt es fest -, eine Datei weiter stand er
unbemerkt weiter. Deshalb rechnet der Selbsttest die Zahl jetzt selbst
nach, statt sich darauf zu verlassen, dass jemand beide Stellen
gleichzeitig anfasst.
"""
stellen = (
('data-pipeline/README.md', r'jede der (\d+) Fehlerarten', fehlerarten, 'Fehlerarten'),
('data-pipeline/README.md', r'meldet deshalb (\d+) Fälle', faelle, 'Faelle'),
# Die CI-Datei nannte dieselbe Zahl unabhaengig noch einmal - und stand
# bis Fassung 0.27.2 auf 23. Wer den Umfang dieser Pruefung dort ablas,
# unterschaetzte ihn ebenso.
(
'.github/workflows/gate.yml',
r'seiner (\d+) Fehlerarten einmal absichtlich',
fehlerarten,
'Fehlerarten',
),
)
fehler = 0
for datei, muster, erwartet, was in stellen:
text = io.open(WURZEL / datei, encoding='utf-8').read()
treffer = re.search(muster, text)
if treffer is None:
print(f' HANDBUCH: keine Angabe zu {was} in {datei} gefunden')
fehler += 1
elif int(treffer.group(1)) != erwartet:
print(
f' HANDBUCH: {datei} nennt {treffer.group(1)} {was}, '
f'gefahren werden {erwartet}'
)
fehler += 1
return fehler
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
# Nicht jeder Fall ist eine Fehlerart: Zwei Faelle sind Gegenproben, die
# gerade *nicht* beanstandet werden duerfen. Getrennt gezaehlt, weil
# data-pipeline/README.md die Fehlerarten als Mass der Abdeckung nennt -
# und diese Zahl wird jetzt gegen den Lauf geprueft.
gegenproben = 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
# Ein Buchstabe aus einem Gesetzeszitat ist keine Nennung einer
# Antwortmoeglichkeit. Der Fall braucht eine Auswahlfrage und passt
# deshalb nicht in die Liste oben.
mc_frage = next(f for f in katalog['fragen'] if f['typ'] == 'mc')
zitat = ' '.join(
f"§ 12 Abs. 1 Nr. 3 Buchstabe {(o.get('label') or '')} WaffG"
for o in mc_frage['optionen']
)
pruefung = Pruefung(katalog, gesetze)
gelaufen += 1
pruefung.erklaerung_pruefen(
mc_frage['id'],
{
**gueltig,
'text': (
'Die Erklaerung nennt keine einzige Antwortmoeglichkeit bei ihrem '
'Gegenstand, sondern zitiert nur Vorschriften mit Buchstabengliederung: '
+ zitat
),
},
)
if any(b.feld.startswith('option[') for b in pruefung.befunde):
print(' erkannt: Buchstabe aus einem Gesetzeszitat statt einer Antwort')
else:
print(' DURCHGELASSEN: Buchstabe aus einem Gesetzeszitat statt einer Antwort')
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]},
},
}
)
gegenproben += 1
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)
gegenproben += 1
if pruefung.befunde:
print(' FALSCHER ALARM bei einer gueltigen Erklaerung:')
for befund in pruefung.befunde:
print(f' {befund}')
fehlgeschlagen += 1
else:
print(' ok: gueltige Erklaerung bleibt unbeanstandet')
fehlgeschlagen += handbuchzahlen_pruefen(gelaufen - gegenproben, gelaufen)
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())