# Datenpipeline: BVA-Fragenkatalog → JSON Wandelt den amtlichen „Fragenkatalog für die Sachkundeprüfung (gemäß § 7 WaffG)" des Bundesverwaltungsamtes (Stand 16.12.2024) in eine strukturierte, barrierefrei nutzbare Datenbasis um. Der Fragenwortlaut bleibt dabei unverändert. ## Ausführen ```bash python data-pipeline/parse_catalog.py python data-pipeline/validate_catalog.py python data-pipeline/diagnose_kreuze.py ``` Der Parser liest `Fragenkatalog_sachkunde_mitAntworten.pdf` aus dem Projektwurzelverzeichnis und schreibt nach `content/katalog/`: | Datei | Inhalt | |---|---| | `katalog.json` | Alle 575 Fragen mit Optionen, Lösungen, Musterantworten | | `assets/*.png` | 17 Prüf- und Zulassungszeichen | | `trennstriche.txt` | Zeilenend-Trennungen, bei denen ein Bindestrich erhalten blieb (QS-Liste) | `validate_catalog.py` prüft das Ergebnis gegen die am Original ermittelten Sollwerte und bricht bei Abweichungen mit Exit-Code 1 ab. Er ist damit als CI-Schritt geeignet. `diagnose_kreuze.py` prüft die Kreuzerkennung **am Bild statt am Vektorpfad** und ist damit die einzige Stelle, die sie unabhängig von der Logik des Parsers belegt (siehe unten, Punkt 2). Auch er antwortet mit Exit-Code 0 oder 1; ein voller Lauf über alle 124 Seiten dauert rund zweieinhalb Sekunden. ```bash python data-pipeline/diagnose_kreuze.py --seiten 6-20 # Ausschnitt python data-pipeline/diagnose_kreuze.py --selbsttest # die Probe prüfen ``` Voraussetzung: `pip install pymupdf` ## Sollwerte (alle am Original verifiziert) | Kennzahl | Wert | |---|---| | Fragen gesamt | 575 | | davon Multiple Choice | 471 | | davon offene Fragen | 104 (103 Freitext + 1 Lückentext) | | Antwortoptionen | 1430 | | angekreuzte Optionen | 629 | | Fragen mit mehreren richtigen Antworten | 114 | | Fragen mit **allen** Optionen richtig | 28 | | Fragen ohne jede richtige Option | 0 | | Fragen mit Abbildung | 23 | | Bild-Assets | 17 | Verteilung je Abschnitt: I.1 = 90, I.2 = 154, I.3 = 35, I.4 = 23, I.5 = 43, II = 92, III = 49, IV = 89. Die letzten beiden Zeilen sind die Randfälle der Kreuzerkennung und zugleich die beiden Fehlerbilder eines Einlesefehlers: alles angekreuzt (Layout nicht erkannt) und nichts angekreuzt (Kreuze verloren). `validate_catalog.py` hält deshalb nicht nur die Anzahl 28 fest, sondern die **Menge** der 28 Fragen samt amtlicher Nummer und PDF-Seite — eine Zählung bliebe grün, wenn eine Frage die Kreuze verlöre und eine andere welche bekäme. Dass die 28 echt sind, ist am Original belegt (siehe unten, Punkt 2). ## Wie die Vorlage gelesen wird Das PDF ist born-digital mit fehlerfreiem Textlayer – kein OCR nötig. Die Fragen stehen in einer unsichtbaren vierspaltigen Tabelle: ``` x 71–103 amtliche Fragennummer (Anker für den Fragenbeginn) x 104–292 Fragetext x 292–540 Antwortoptionen bzw. Musterantwort x 503–529 Ankreuzkästchen ``` Beim Parsen sind fünf Eigenheiten der Vorlage zu beachten; jede war Ursache eines konkreten Fehlers und ist im Code kommentiert: 1. **Fragetext und erste Antwortoption stehen oft in derselben Textzeile.** Getrennt wird deshalb nach x-Koordinate, nicht nach Zeilen. 2. **Lösungen stehen nicht im Text**, sondern als Vektorgrafik: ein Kästchen ist angekreuzt, wenn zwei Diagonalsegmente vollständig darin liegen. Diese Erkennung steht vor einer Lücke, nicht vor einem Schwellwert: Nachgemessen über alle 1430 Kästchen liegen in genau 629 jeweils **zwei** Diagonalsegmente, in den übrigen 801 **keines** — kein einziges hat eines, drei oder vier. Unabhängig gegengeprüft durch Rendern derselben Kästchen bei 8-facher Vergrößerung: Tintendichte im Inneren 0,0000 bei allen 801, 0,1653 bis 0,1978 bei allen 629, null Widersprüche. Diese Gegenprobe ist als `diagnose_kreuze.py` wiederholbar und gehört deshalb nicht zu den Erkundungsskripten unten. Sie ist die einzige Prüfung des Projekts, die nicht wieder über Vektorpfade urteilt: Ein Denkfehler in der Geometrie stünde sonst in Umsetzung und Prüfung gleichermaßen. Beide Verfahren teilen genau eine Annahme — wo ein Kästchen liegt — und keine darüber, was ein Kreuz ist. `--selbsttest` erzeugt jedes der vier Fehlerbilder absichtlich (Vektor sieht ein Kreuz zu viel, eines zu wenig; Tinte im leeren Kästchen; angekreuztes Kästchen fast leer) und prüft, dass die Probe anschlägt. Anlass der Nachmessung waren die 28 Fragen, bei denen **alle** Optionen angekreuzt sind — der Verdacht, ein Layout sei nicht erkannt und vorsichtshalber alles genommen worden. Alle 28 Tabellenzeilen wurden zusätzlich als Bild betrachtet: Die Kreuze stehen so in der Vorlage. Es sind durchweg Aufzählungsfragen, bei denen jede Nennung richtig ist („Welche Munitionsarten sind vom Waffengesetz erfasst?" — Patronenmunition, hülsenlose Munition, pyrotechnische Munition). 3. **Kästchen sitzen vertikal mittig zur Option**, bei mehrzeiligen Optionen also unterhalb ihrer ersten Zeile. Die Optionsgrenzen werden daher aus den Labels („a)", „b)", …) am linken Spaltenrand abgeleitet; Kästchen werden anschließend der Reihenfolge nach zugeordnet. 4. **Kernelemente der Musterantworten sind unterstrichen** – Füllrechtecke der Höhe ≈ 0,84 pt, unterscheidbar von Tabellenlinien (≈ 0,48 pt). Sie werden als `hervorgehoben`-Segmente übernommen und tragen später das Freitext-Training. 5. **Ein Prüfzeichen beginnt oberhalb seines eigenen Labels.** Besteht eine Antwortmöglichkeit nur aus einem Zeichen und trägt keinen Text, setzt der Satz das Zeichen senkrecht mittig in seine Tabellenzeile – das Label steht dann *innerhalb* des Bildrechtecks. Liegt genau ein Label im Bild, gilt es; sonst entscheidet die Bildmitte. Nachgemessen: 44 Bilder stehen in Antwortspalten, bei 38 liegt kein Label im Bild. Beide Zahlen rechnet `parse_catalog.py` bei jedem Lauf nach und bricht ab, wenn sie nicht mehr stimmen — bis Fassung 0.27.2 stand hier 43, und die Zahl war die einzige Begründung dafür, dass die Label-Regel vor der Bildmitte steht. Bis Fassung 0.19.2 stand hier ein Zerschneider, der ein Bild an der leersten Bildzeile in zwei Hälften teilte, sobald eine Option leer blieb. Er sprang im ganzen Katalog genau einmal an – bei Frage 3.05 – und lag dort falsch: Er trennte das Doppelzeichen *BKA-Raute über PTB-Trapez*, das in Frage 2.70 ungeteilt eine einzige Antwortmöglichkeit bildet („Reizstoff-Sprühdosen mit dem Zeichen"), und hängte die obere Hälfte an die falsche Antwort. Bleibt heute eine Antwortmöglichkeit ohne Text und ohne Bild, rät nichts mehr – der Katalog vermerkt es als Warnung. Zeilenend-Trennungen werden zusammengeführt: vor Bindewörtern bleibt der Ergänzungsstrich („Kinder- und Jugendarbeit"), vor Großschreibung der echte Bindestrich („Physikalisch-Technische"), sonst wird das Wort geschlossen („Signalge-" + „bung" → „Signalgebung"). ## Datenformat ```jsonc { "meta": { "titel", "herausgeber", "stand", "quellenangabe", "quelldatei_sha256", "fragen_gesamt" }, "kapitel": [ { "id": "I", "titel", "abschnitte": [ { "id": "I.1", "titel" } ] } ], "bilder": [ { "id", "datei", "breite", "hoehe", "alt": null, "beschreibung": null } ], "fragen": [ { "id": "I.1-01", // stabile ID: Kapitelpräfix + laufende Nummer "amtliche_nummer": "1.01", // Nummer wie im Katalog, bleibt sichtbar "kapitel": "I", "abschnitt": "I.1", "typ": "mc" | "freitext" | "lueckentext", "seite": 6, "frage": { "text": "…", "segmente": [ { "t": "…", "h": true } ] }, "bilder": [ "zeichen-…" ], // bei typ == "mc": "optionen": [ { "label": "a", "inhalt": { "text", "segmente" }, "korrekt": true, "bilder": [] } ], // sonst: "musterantwort": { "text": "…", "segmente": [ … ] } } ] } ``` `segmente` trägt die Auszeichnung mit: `{"t": "…"}` ist normaler Text, `{"t": "…", "h": true}` ein hervorgehobenes Kernelement. `text` ist dieselbe Zeichenfolge ohne Auszeichnung und dient Suche und Vorlesefunktion. ## Offene Punkte - **Bildtexte:** liegen für alle 17 Bilder in `content/alttexte.json` und werden beim Erzeugen in `katalog.json` eingesetzt. Sie sind eigener, nicht-amtlicher Inhalt. Je Zeichen zwei Stufen (Prüfplan, Szenario S4): `alt` ist der Alternativtext des Bildes, `beschreibung` die erklärende Bedeutung, die die Anwendung erst **nach** dem Beantworten nachreicht und im Prüfungslauf gar nicht zeigt. Achtung bei den Fragen 3.05, 3.24 und IV-39: Dort sind die Zeichen selbst die Antwortoptionen – der Alt-Text beschreibt deshalb rein die Form („Bundesadler über dem Buchstaben N") und verrät weder Bedeutung noch Lösung; genau dafür gibt es die zweite Stufe. Die Formulierungen der `beschreibung` stammen aus den Erklärungstexten in `content/erklaerungen.json`, nicht aus einer eigenen Recherche. - **Lückentext 5.01**: Die Lücken sind im Fragetext derzeit nur Leerstellen. Für ein echtes Lückentext-Training müssen sie noch markiert werden (Einzelfall). - **Manuelle Sichtprüfung** aller 575 Fragen gegen das PDF steht aus; die automatischen Prüfungen decken Struktur und Vollständigkeit ab, nicht jeden Buchstaben. ## Erklärungstexte und Rechtsfundstellen Der amtliche Katalog nennt die richtige Antwort, aber nie den Grund. Die Erklärungen in `content/erklaerungen.json` liefern ihn nach – eigener redaktioneller Inhalt, streng getrennt vom amtlichen Werk. Bei Waffenrecht ist eine erfundene Fundstelle kein Schönheitsfehler: Wer sich darauf verlässt, lernt geltendes Recht falsch. Deshalb steht vor dem Inhalt die Prüfgrundlage. ### Ablauf ```bash # 1. Amtliche Gesetzestexte holen (einmalig bzw. bei Gesetzesänderung) cd content/gesetze for g in waffg_2002 awaffv beschg beschussv stgb sprengg_1976 sprengv_1; do curl -sSO "https://www.gesetze-im-internet.de/$g/xml.zip" && unzip -oq xml.zip && rm xml.zip done cd ../.. # 2. Prüfbaren Index bauen python data-pipeline/gesetze_index.py # 3. Beim Schreiben nachschlagen python data-pipeline/gesetz.py WaffG "§ 12" --gliederung python data-pipeline/gesetz.py --suche "Anscheinswaffe" python data-pipeline/fragen.py --abschnitt I.1 --von 0 --anzahl 10 # 4. Teilstücke zusammenführen python data-pipeline/erklaerungen_zusammenfuehren.py # 5. Prüfen – ohne bestandene Prüfung wird nicht ausgeliefert python data-pipeline/pruefe_erklaerungen.py python data-pipeline/pruefe_erklaerungen.py --selbsttest # 6. Die zitierten Normtexte für die Auslieferung ausschneiden python data-pipeline/normtexte_bauen.py python data-pipeline/normtexte_bauen.py --selbsttest ``` ### Warum `normtexte.json` neben dem Index steht Der Index enthält die sieben Gesetze **vollständig** – 914 Normen, darunter katalogfremde wie § 173 StGB. Ausgeliefert wird nur, was in Erklärungen oder Glossar wirklich zitiert wird: 142 Paragrafen und 6 Anlagen, rund 500 KiB. Ohne diese Datei führte „Im Gesetz nachlesen“ ins Leere – eine ausdrücklich vollständig offline arbeitende Software forderte zu etwas auf, das offline gerade nicht ging. Anlagen kennen keine Absatzgliederung und sind seitenlang. Sie werden in ihre eigenen Gliederungsblöcke zerlegt, und zwar **verlustfrei**: Die Blöcke ergeben aneinandergehängt wieder Zeichen für Zeichen den Ausgangstext. Das Skript rechnet es für jede Anlage nach und bricht ab, statt etwas zu schreiben. Ein Block ist ein Sprungziel, kein Ausschnitt – ein falsch gesetzter Schnitt wäre ein verfälschtes Gesetzeszitat in einer Lernsoftware für eine Rechtsprüfung. ### Die Themengliederung ```bash python data-pipeline/themen_bauen.py python data-pipeline/themen_bauen.py --selbsttest ``` Der amtliche Katalog gliedert **nur Kapitel I** in Abschnitte. Für die 230 Fragen der Kapitel II bis IV schreibt `themen_bauen.py` eine redaktionelle Feingliederung nach `content/themen.json` – 29 Gruppen, von Hand geschrieben. Das Skript schreibt nur, wenn **jede** Frage dieser Kapitel in genau einer Gruppe liegt. Eine lückenhafte Gliederung wäre schlimmer als keine: „Thema üben“ überginge dann Fragen, ohne es zu sagen. Nach jeder Katalogänderung deshalb erneut laufen lassen – neue Fragen fallen sonst durch das Raster, und zwar unbemerkt. Nach jeder Gesetzesänderung: erst Schritt 2, dann Schritt 5, dann Schritt 6. Wer Schritt 6 vergisst, liefert die Normtexte des alten Standes aus, während die Fundstellen schon gegen den neuen geprüft wurden. ### Was die Prüfung leistet `pruefe_erklaerungen.py` schlägt jede Fundstelle im Index nach: Gesetz, Paragraf, Absatz, Nummer, Buchstabe. Was dort nicht steht, existiert nicht. Bei Normen ohne Absatzgliederung (etwa § 37a WaffG) wird gegen die Aufzählungsmarken im Normtext geprüft, Satzangaben gegen die mögliche Satzanzahl, Anlagen-Stellen per Textsuche. Die Ausgabe unterscheidet „vollständig geprüft“ (jede Angabe nachgewiesen) von „nur Existenz geprüft“. Der Selbsttest erzeugt jede der 31 Fehlerarten absichtlich – eine Prüfung, die nichts beanstandet, ist von einer funktionierenden nicht zu unterscheiden. Dazu zwei Gegenproben, die gerade **nicht** beanstandet werden dürfen; der Lauf meldet deshalb 33 Fälle. Diese beiden Zahlen prüft der Selbsttest selbst gegen diese Datei nach – bis Fassung 0.27.2 stand hier unverändert die 22 von Fassung 0.21.1, und wer die Abdeckung daran ablas, unterschätzte sie deutlich. ### Was sie 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 – genau das ist beim Erproben passiert: Eine Erklärung zum Begriff „Umgang“ zitierte § 2 Abs. 2 WaffG, der die Erlaubnispflicht regelt und nicht den Begriff. Richtig ist § 1 Abs. 3 WaffG. Die Fundstelle kam durch die Prüfung. Dagegen hilft nur, dass jede Erklärung von einer zweiten, unabhängigen Stelle gegen den Normtext gelesen wird. Fehlt eine gesetzliche Grundlage – bei Sicherheitsregeln und Waffentechnik der Normalfall –, verlangt das Feld `ohneFundstelleGrund`, das auszusprechen, statt eine Fundstelle zu erfinden. ### Gesetzesstand `content/erklaerungen.json` vermerkt im Kopf den Stand jedes zitierten Gesetzes, wörtlich aus dem amtlichen XML. Die Prüfung vergleicht ihn mit dem Index und schlägt an, wenn beides auseinanderläuft. Ändert sich ein Gesetz, ist damit erkennbar, worauf sich die Erklärungen bezogen haben. Die XML-Rohdateien sind nicht versioniert (siehe `.gitignore`); versioniert ist nur der daraus erzeugte Index. ## Glossar der Fachbegriffe Das Waffenrecht benutzt Alltagswörter mit eigener Bedeutung. „Führen" heißt nicht lenken, „Erwerben" hat mit Kaufen nichts zu tun. Wer das nicht weiß, versteht die Frage nicht – unabhängig davon, wie gut er den Stoff kann. Genau darauf zielen WCAG 3.1.3 (Ungewöhnliche Wörter) und 3.1.4 (Abkürzungen); beide stehen im Prüfplan dieses Projekts als Zusage. ### Ablauf ```bash # Wortformen gegen Katalog und Erklärungen prüfen python data-pipeline/fragen.py --wortform führt geführt Führens # Teilstücke zusammenführen python data-pipeline/glossar_zusammenfuehren.py # Prüfen – ohne bestandene Prüfung wird nicht ausgeliefert python data-pipeline/pruefe_glossar.py python data-pipeline/pruefe_glossar.py --selbsttest ``` ### Wortformen Deutsch flektiert. Ohne die Wortformen bliebe ein Begriff in genau den Fragen unerkannt, in denen er gebraucht wird. Gesucht wird mit derselben Wortgrenze wie in der Anwendung – `` taugt dafür nicht, weil es nur ASCII kennt und in „Schießstätte" an jedem ß eine Grenze sähe. Ein Eintrag **darf** ohne Wortform bleiben. Er erscheint dann nur in der Glossaransicht und an keiner Frage. Das ist bei Abkürzungen normal, die in den Erklärungen vorkommen, aber nicht im amtlichen Fragenwortlaut. ### Woher die Bestimmungen stammen Wo das Gesetz einen Begriff selbst bestimmt, gibt der Eintrag diese Bestimmung wieder – nicht eine eigene Umschreibung. Die waffenrechtlichen Begriffe stehen in Anlage 1 Abschnitt 2 WaffG, die technischen in Abschnitt 1. Jede Fundstelle wird mit derselben Prüfung wie bei den Erklärungen nachgewiesen; `pruefe_glossar.py` erbt sie wörtlich, damit für beide derselbe Maßstab gilt. ### Selbsttest `--selbsttest` verletzt jede Regel einmal absichtlich – Eindeutigkeit, Wortformen, Querverweise, Form, redliche Lücken, Gesetzesstand – und besteht nur, wenn jede Verletzung auch beanstandet wird. Dazu drei Gegenproben: Ein gültiger Eintrag darf weder Befund noch Hinweis auslösen, die Wortsuche muss eine echte Form im Katalog wirklich finden (sonst wäre „ohne Vorkommen" die Standardantwort und keiner der Wortform-Fälle sagte etwas aus), und der ausgelieferte Bestand muss unbeanstandet durchlaufen. Zusammen 34 Fälle. Bis Fassung 0.21.0 war die Fahne wirkungslos: Sie stand im Modulkopf, aber `main()` las `sys.argv` nicht. Der Aufruf mit `--selbsttest` fuhr stumm dieselbe Bestandsprüfung und meldete Exit-Code 0 – in einer Prüfkette lief damit dieselbe Prüfung zweimal, während die glossareigenen Regeln von keinem Selbsttest gedeckt waren. Ein vertippter Schalter fällt heute mit Exit-Code 2 auf, statt ersatzweise die Bestandsprüfung zu fahren. ## Rechtlicher Hinweis Der Katalog ist ein amtliches Werk; sein Wortlaut wird unverändert übernommen und mit Quellenangabe geführt (`meta.quellenangabe`). Eigene didaktische Inhalte (Erklärungen, Eselsbrücken) sind davon getrennt zu halten und als nicht-amtlich zu kennzeichnen. Details siehe `PLAN.md`, Abschnitt 2.5. ## Erkundungsskripte `explore_layout.py`, `explore_details.py` und `diagnose_columns.py` haben die oben genannten Layout-Konstanten am Original vermessen. Sie gehören nicht zum Produktivlauf, dokumentieren aber, woher die Zahlen stammen, und sind bei einer neuen Katalogfassung der erste Anlaufpunkt.