waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
/ data-pipeline validate_catalog.py
| 1 | # -*- coding: utf-8 -*- |
| 2 | """Prüft den extrahierten Katalog gegen Sollwerte und Plausibilitätsregeln. |
| 3 | |
| 4 | Sollwerte stammen aus der unabhängigen Analyse des Originals (siehe PLAN.md, |
| 5 | Abschnitt 2.2). Abweichungen sind Fehler, keine Warnungen. |
| 6 | """ |
| 7 | from __future__ import annotations |
| 8 | |
| 9 | import io |
| 10 | import json |
| 11 | import re |
| 12 | import sys |
| 13 | from collections import Counter |
| 14 | from pathlib import Path |
| 15 | |
| 16 | sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8") |
| 17 | |
| 18 | OUT = Path(__file__).resolve().parent.parent / "content" / "katalog" |
| 19 | data = json.loads((OUT / "katalog.json").read_text(encoding="utf-8")) |
| 20 | fragen = data["fragen"] |
| 21 | |
| 22 | SOLL_ABSCHNITTE = { |
| 23 | ("I", "I.1"): (90, 69, 21), ("I", "I.2"): (154, 135, 19), |
| 24 | ("I", "I.3"): (35, 29, 6), ("I", "I.4"): (23, 22, 1), |
| 25 | ("I", "I.5"): (43, 29, 14), ("II", None): (92, 79, 13), |
| 26 | ("III", None): (49, 48, 1), ("IV", None): (89, 60, 29), |
| 27 | } |
| 28 | SOLL_OPTIONEN = 1430 |
| 29 | SOLL_KREUZE = 629 |
| 30 | SOLL_MEHRFACH = 114 |
| 31 | SOLL_OPT_VERTEILUNG = {2: 10, 3: 445, 4: 10, 5: 2, 6: 3, 7: 1} |
| 32 | SOLL_RICHTIG_VERTEILUNG = {1: 357, 2: 80, 3: 27, 4: 5, 5: 1, 6: 1} |
| 33 | |
| 34 | # Auswahlfragen, bei denen die Vorlage **alle** Optionen ankreuzt. |
| 35 | # |
| 36 | # Sie fielen beim Vermessen der Ratewahrscheinlichkeit auf (siehe |
| 37 | # docs/entscheidung-ratewahrscheinlichkeit.md, Abschnitt 2): Sie allein heben |
| 38 | # die mittlere Ratechance des Werkszustands von 0,332 auf 0,3715, also über |
| 39 | # 1/3. Der Verdacht lag deshalb nahe, dass hier ein Einleserfehler alles |
| 40 | # ankreuzt – so wie 2024 ein Prüfzeichen an der falschen Antwort landete |
| 41 | # (docs/stand.md 7.12). |
| 42 | # |
| 43 | # Gegengeprüft wurde am Original, dreifach: |
| 44 | # |
| 45 | # 1. Vektorseitig: über alle 1430 Kästchen des PDF liegen in genau 629 |
| 46 | # jeweils **zwei** Diagonalsegmente, in den übrigen 801 **keines**. Kein |
| 47 | # einziges Kästchen hat eines, drei oder vier – die Erkennung steht vor |
| 48 | # einer Lücke, nicht vor einem Schwellwert. |
| 49 | # 2. Pixelseitig, unabhängig davon: dieselben Kästchen bei 8-facher |
| 50 | # Vergrößerung gerendert und die Tintendichte im Inneren gemessen. Die |
| 51 | # 801 haben exakt 0,0000, die 629 zwischen 0,1653 und 0,1978. Null |
| 52 | # Widersprüche zur Vektorprüfung. |
| 53 | # 3. Mit dem Auge: alle 28 Tabellenzeilen als Bild betrachtet. In jeder sind |
| 54 | # die Kreuze tatsächlich gesetzt. |
| 55 | # |
| 56 | # Es sind durchweg Aufzählungsfragen, bei denen die Vorlage jede Nennung als |
| 57 | # richtig führt („Welche Munitionsarten sind vom Waffengesetz erfasst?"). Die |
| 58 | # 28 sind also eine Eigenschaft des amtlichen Katalogs, kein Einlesefehler. |
| 59 | # |
| 60 | # Festgehalten wird deshalb nicht nur die Anzahl, sondern die Menge selbst: |
| 61 | # Eine reine Zählung bliebe grün, wenn ein Einlesefehler einer Frage die |
| 62 | # Kreuze nähme und einer anderen welche gäbe. Zu jedem Eintrag steht die |
| 63 | # amtliche Nummer und die PDF-Seite, auf der er nachzuschlagen ist. |
| 64 | SOLL_ALLE_RICHTIG = { |
| 65 | "I.1-02": ("1.02", 6), |
| 66 | "I.1-11": ("1.11", 7), |
| 67 | "I.1-31": ("1.31", 11), |
| 68 | "I.1-86": ("1.86", 23), |
| 69 | "I.2-38": ("2.38", 33), |
| 70 | "I.2-41": ("2.41", 34), |
| 71 | "I.2-59": ("2.59", 38), |
| 72 | "I.2-96": ("2.96", 46), |
| 73 | "I.2-147": ("2.147", 58), |
| 74 | "I.2-152": ("2.152", 58), |
| 75 | "I.3-04": ("3.04", 60), |
| 76 | "I.3-27": ("3.27", 65), |
| 77 | "I.3-31": ("3.31", 66), |
| 78 | "I.3-33": ("3.33", 66), |
| 79 | "I.5-06": ("5.06", 73), |
| 80 | "I.5-38": ("5.38", 79), |
| 81 | "II-13": ("13", 82), |
| 82 | "II-33": ("33", 86), |
| 83 | "II-75": ("75", 93), |
| 84 | "III-11": ("11", 100), |
| 85 | "III-30": ("30", 103), |
| 86 | "III-46": ("46", 107), |
| 87 | "IV-04": ("04", 108), |
| 88 | "IV-08": ("08", 109), |
| 89 | "IV-15": ("15", 110), |
| 90 | "IV-32": ("32", 114), |
| 91 | "IV-44": ("44", 116), |
| 92 | "IV-46": ("46", 116), |
| 93 | } |
| 94 | |
| 95 | fehler: list[str] = [] |
| 96 | warnungen: list[str] = [] |
| 97 | |
| 98 | |
| 99 | def pruefe(bedingung: bool, meldung: str, hart: bool = True) -> None: |
| 100 | if not bedingung: |
| 101 | (fehler if hart else warnungen).append(meldung) |
| 102 | |
| 103 | |
| 104 | # ---------------------------------------------------------- Struktur & Zahlen |
| 105 | pruefe(len(fragen) == 575, f"Fragenzahl {len(fragen)}, erwartet 575") |
| 106 | |
| 107 | je_abschnitt: Counter = Counter() |
| 108 | je_abschnitt_typ: Counter = Counter() |
| 109 | for f in fragen: |
| 110 | key = (f["kapitel"], f["abschnitt"]) |
| 111 | je_abschnitt[key] += 1 |
| 112 | je_abschnitt_typ[(key, "mc" if f["typ"] == "mc" else "offen")] += 1 |
| 113 | |
| 114 | for key, (ges, mc, offen) in SOLL_ABSCHNITTE.items(): |
| 115 | ist_ges = je_abschnitt[key] |
| 116 | ist_mc = je_abschnitt_typ[(key, "mc")] |
| 117 | ist_offen = je_abschnitt_typ[(key, "offen")] |
| 118 | label = key[1] or f"Kapitel {key[0]}" |
| 119 | pruefe(ist_ges == ges, f"{label}: {ist_ges} Fragen, erwartet {ges}") |
| 120 | pruefe(ist_mc == mc, f"{label}: {ist_mc} MC-Fragen, erwartet {mc}") |
| 121 | pruefe(ist_offen == offen, f"{label}: {ist_offen} offene Fragen, erwartet {offen}") |
| 122 | |
| 123 | # ------------------------------------------------------- Optionen und Kreuze |
| 124 | optionen_gesamt = sum(len(f.get("optionen", [])) for f in fragen) |
| 125 | kreuze_gesamt = sum(sum(1 for o in f.get("optionen", []) if o["korrekt"]) for f in fragen) |
| 126 | mehrfach = sum(1 for f in fragen |
| 127 | if sum(1 for o in f.get("optionen", []) if o["korrekt"]) > 1) |
| 128 | pruefe(optionen_gesamt == SOLL_OPTIONEN, f"{optionen_gesamt} Optionen, erwartet {SOLL_OPTIONEN}") |
| 129 | pruefe(kreuze_gesamt == SOLL_KREUZE, f"{kreuze_gesamt} Kreuze, erwartet {SOLL_KREUZE}") |
| 130 | pruefe(mehrfach == SOLL_MEHRFACH, f"{mehrfach} Mehrfachauswahl-Fragen, erwartet {SOLL_MEHRFACH}") |
| 131 | |
| 132 | opt_vert = Counter(len(f["optionen"]) for f in fragen if f["typ"] == "mc") |
| 133 | richtig_vert = Counter(sum(1 for o in f["optionen"] if o["korrekt"]) |
| 134 | for f in fragen if f["typ"] == "mc") |
| 135 | pruefe(dict(opt_vert) == SOLL_OPT_VERTEILUNG, |
| 136 | f"Optionsanzahl-Verteilung {dict(sorted(opt_vert.items()))}, erwartet {SOLL_OPT_VERTEILUNG}") |
| 137 | pruefe(dict(richtig_vert) == SOLL_RICHTIG_VERTEILUNG, |
| 138 | f"Richtig-Verteilung {dict(sorted(richtig_vert.items()))}, erwartet {SOLL_RICHTIG_VERTEILUNG}") |
| 139 | |
| 140 | # ------------------------------- Beide Randfälle der Kreuzerkennung festnageln |
| 141 | # Die Kreuze sind das Einzige am Katalog, was die Anwendung als *Wahrheit* |
| 142 | # lehrt. Ihre beiden Ausreißerfälle sind zugleich die beiden Fehlerbilder eines |
| 143 | # Einlesefehlers, und sie sind hier bewusst getrennt festgehalten: |
| 144 | # |
| 145 | # alles angekreuzt – der Einleser hätte ein Layout nicht erkannt und |
| 146 | # vorsichtshalber jedes Kästchen genommen. Sollwert: die |
| 147 | # 28 Fragen aus SOLL_ALLE_RICHTIG, am Original belegt. |
| 148 | # nichts angekreuzt – der Einleser hätte die Kreuze verloren. Sollwert: keine |
| 149 | # einzige Frage. Die Frage darunter (Zeile „MC-Frage ohne |
| 150 | # richtige Antwort") nennt den Einzelfall beim Namen; |
| 151 | # diese Zeile hält fest, dass es die Fehlerart im ganzen |
| 152 | # Katalog nicht gibt. |
| 153 | ist_alle_richtig = { |
| 154 | f["id"]: (f["amtliche_nummer"], f["seite"]) |
| 155 | for f in fragen |
| 156 | if f["typ"] == "mc" and f["optionen"] and all(o["korrekt"] for o in f["optionen"]) |
| 157 | } |
| 158 | if ist_alle_richtig != SOLL_ALLE_RICHTIG: |
| 159 | neu = sorted(set(ist_alle_richtig) - set(SOLL_ALLE_RICHTIG)) |
| 160 | weg = sorted(set(SOLL_ALLE_RICHTIG) - set(ist_alle_richtig)) |
| 161 | verschoben = sorted(k for k in set(ist_alle_richtig) & set(SOLL_ALLE_RICHTIG) |
| 162 | if ist_alle_richtig[k] != SOLL_ALLE_RICHTIG[k]) |
| 163 | pruefe(False, |
| 164 | f"Fragen mit allen Optionen richtig weichen ab " |
| 165 | f"({len(ist_alle_richtig)} gefunden, {len(SOLL_ALLE_RICHTIG)} erwartet) – " |
| 166 | f"neu {neu}, entfallen {weg}, Nummer/Seite geändert {verschoben}") |
| 167 | |
| 168 | ohne_richtige = sorted(f["id"] for f in fragen |
| 169 | if f["typ"] == "mc" and not any(o["korrekt"] for o in f["optionen"])) |
| 170 | pruefe(not ohne_richtige, |
| 171 | f"Auswahlfragen ohne jede richtige Option: {len(ohne_richtige)} – {ohne_richtige[:10]}") |
| 172 | |
| 173 | # ------------------------------------------------------ Inhaltliche Regeln |
| 174 | ids = [f["id"] for f in fragen] |
| 175 | pruefe(len(set(ids)) == len(ids), "Doppelte Fragen-IDs vorhanden") |
| 176 | |
| 177 | for f in fragen: |
| 178 | fid = f["id"] |
| 179 | frage_text = f["frage"]["text"] |
| 180 | pruefe(bool(frage_text.strip()), f"{fid}: leerer Fragetext") |
| 181 | pruefe(len(frage_text) >= 8, f"{fid}: verdächtig kurzer Fragetext: {frage_text!r}", hart=False) |
| 182 | pruefe(" " not in frage_text, f"{fid}: doppelte Leerzeichen im Fragetext", hart=False) |
| 183 | |
| 184 | if f["typ"] == "mc": |
| 185 | korrekt = [o for o in f["optionen"] if o["korrekt"]] |
| 186 | pruefe(len(korrekt) >= 1, f"{fid}: MC-Frage ohne richtige Antwort") |
| 187 | pruefe(len(f["optionen"]) >= 2, f"{fid}: MC-Frage mit < 2 Optionen") |
| 188 | for o in f["optionen"]: |
| 189 | otext = o["inhalt"]["text"] |
| 190 | pruefe(bool(otext.strip() or o["bilder"]), f"{fid} Option {o['label']}: leer") |
| 191 | pruefe(not re.match(r"^[a-h]\)", otext.strip()), |
| 192 | f"{fid} Option {o['label']}: Label nicht abgetrennt: {otext[:30]!r}") |
| 193 | labels = [o["label"] for o in f["optionen"]] |
| 194 | pruefe(labels == sorted(labels), f"{fid}: Optionsreihenfolge {labels}") |
| 195 | else: |
| 196 | pruefe(bool(f["musterantwort"]["text"].strip()), f"{fid}: leere Musterantwort") |
| 197 | |
| 198 | pruefe("warnungen" not in f, f"{fid}: Parser-Warnung {f.get('warnungen')}", hart=False) |
| 199 | |
| 200 | # --------------------------------------------------- Hervorhebungen prüfen |
| 201 | # Wörter, die allein nie ein Kernelement sein können. Bewusst knapp gehalten: |
| 202 | # Die Liste soll Bruchstücke erkennen, nicht Inhalte bewerten. |
| 203 | FUNKTIONSWOERTER = frozenset(""" |
| 204 | der die das den dem des ein eine einer eines einem einen und oder aber auch als |
| 205 | am an auf aus bei bis durch für gegen im in ist sind war waren mit nach ohne |
| 206 | seit so um von vor zu zum zur über werden wird wurde wurden kann können darf |
| 207 | dürfen muss müssen soll sollen nicht kein keine nur noch wenn dass es sie er |
| 208 | ihm ihn ihr sich man dann wie wo was welche welcher welches diese dieser dieses |
| 209 | hat haben hatte sein seine seiner seines sowie damit dabei dazu darauf |
| 210 | """.split()) |
| 211 | |
| 212 | mit_hervorhebung = sum( |
| 213 | 1 for f in fragen if f["typ"] != "mc" |
| 214 | and any(s.get("h") for s in f["musterantwort"]["segmente"])) |
| 215 | pruefe(35 <= mit_hervorhebung <= 55, |
| 216 | f"{mit_hervorhebung} offene Fragen mit hervorgehobenen Kernelementen, erwartet ~43", |
| 217 | hart=False) |
| 218 | |
| 219 | # Diese Zählung war bis 0.17.0 die einzige Prüfung, die `h` überhaupt anfasste. |
| 220 | # Sie war grün, während 14 Listen zerrissene Läufe enthielten und die |
| 221 | # Anwendung unter „Diese Kernelemente muss Ihre Antwort enthalten" als ersten |
| 222 | # Punkt das Wort „zum" zeigte. Eine Zählung sagt nichts über den Inhalt. |
| 223 | # |
| 224 | # Die beiden folgenden Prüfungen sind hart, weil ein Rückfall genau so |
| 225 | # aussähe wie damals: Der Katalog baut durch, die Zahl stimmt, und der Fehler |
| 226 | # steht nur in der Anzeige. Gegengeprüft am Katalog vor der Behebung: 22 |
| 227 | # Fehler, genau die 18 zerrissenen Läufe und die 4 Funktionswort-Einträge. |
| 228 | # |
| 229 | # NICHT erkennbar ist hier die andere Fehlerart – ein Wort, das die |
| 230 | # Unterstreichungserkennung gar nicht erst erfasst hat (Frage IV-36, |
| 231 | # „Unterklasse P2"). Dieser Verlust entsteht vor der Segmentbildung und |
| 232 | # hinterlässt in der Ausgabe keine Spur. Dagegen hilft nur die Schwelle |
| 233 | # `UNDERLINE_MIN_OVERLAP` in parse_catalog.py, nicht eine Prüfung hier. |
| 234 | for f in fragen: |
| 235 | if f["typ"] == "mc": |
| 236 | continue |
| 237 | fid = f["id"] |
| 238 | segmente = f["musterantwort"]["segmente"] |
| 239 | |
| 240 | # 1. Kein markierter Lauf darf unmittelbar an einen anderen grenzen – |
| 241 | # dazwischen stünde sonst nur der Zeilenumbruch, der ihn zerriss. |
| 242 | for i in range(len(segmente) - 1): |
| 243 | if segmente[i].get("h") and segmente[i + 1].get("h"): |
| 244 | pruefe(False, f"{fid}: zwei markierte Läufe grenzen aneinander " |
| 245 | f"({segmente[i]['t'][-20:]!r} | {segmente[i+1]['t'][:20]!r})") |
| 246 | for i in range(len(segmente) - 2): |
| 247 | if (segmente[i].get("h") and segmente[i + 2].get("h") |
| 248 | and not segmente[i + 1].get("h") |
| 249 | and not segmente[i + 1]["t"].strip()): |
| 250 | pruefe(False, f"{fid}: markierter Lauf am Zeilenumbruch zerrissen " |
| 251 | f"({segmente[i]['t'][-20:]!r} … {segmente[i+2]['t'][:20]!r})") |
| 252 | |
| 253 | # 2. Kein Kernelement darf ausschließlich aus Funktionswörtern bestehen. |
| 254 | # Genau so sahen die Bruchstücke aus: „zum", „sind", „nicht". |
| 255 | for seg in segmente: |
| 256 | if not seg.get("h"): |
| 257 | continue |
| 258 | wortlaut = seg["t"].strip() |
| 259 | if not wortlaut: |
| 260 | continue |
| 261 | woerter = [w for w in re.findall(r"[^\W\d_]+", wortlaut, re.UNICODE)] |
| 262 | pruefe(bool(woerter) and not all(w.lower() in FUNKTIONSWOERTER for w in woerter), |
| 263 | f"{fid}: Kernelement besteht nur aus Funktionswörtern: {wortlaut!r}") |
| 264 | |
| 265 | # ------------------------------------------------------------ Bildzuordnung |
| 266 | fragen_mit_bild = sum(1 for f in fragen |
| 267 | if f["bilder"] or any(o.get("bilder") for o in f.get("optionen", []))) |
| 268 | pruefe(fragen_mit_bild == 23, f"{fragen_mit_bild} Fragen mit Bild, erwartet 23") |
| 269 | asset_ids = {b["id"] for b in data["bilder"]} |
| 270 | for f in fragen: |
| 271 | for bid in f["bilder"] + [b for o in f.get("optionen", []) for b in o.get("bilder", [])]: |
| 272 | pruefe(bid in asset_ids, f"{f['id']}: unbekannte Bild-ID {bid}") |
| 273 | pruefe((OUT / "assets" / f"{bid}.png").exists(), f"{f['id']}: Bilddatei {bid}.png fehlt") |
| 274 | |
| 275 | # Barrierefreiheit: jede Abbildung braucht einen Alternativtext (WCAG 1.1.1) |
| 276 | for b in data["bilder"]: |
| 277 | alt = (b.get("alt") or "").strip() |
| 278 | pruefe(bool(alt), f"Bild {b['id']}: Alternativtext fehlt") |
| 279 | pruefe(len(alt) >= 15, f"Bild {b['id']}: Alternativtext zu knapp: {alt!r}", hart=False) |
| 280 | # Zweite Stufe des Bildkonzepts (Prüfplan S4): die erklärende Beschreibung, |
| 281 | # die der Lernmodus nach dem Antworten nachreicht. Weich, nicht hart: |
| 282 | # Eine fehlende Beschreibung lässt die Anwendung funktionieren – sie soll |
| 283 | # aber sichtbar bleiben, bis ein Mensch sie aus belegbaren Quellen ergänzt. |
| 284 | beschreibung = (b.get("beschreibung") or "").strip() |
| 285 | pruefe(bool(beschreibung), |
| 286 | f"Bild {b['id']}: erklärende Beschreibung fehlt", hart=False) |
| 287 | |
| 288 | # ------------------------------------------------------------------ Ausgabe |
| 289 | print("=" * 68) |
| 290 | print(f"Fragen: {len(fragen)} Optionen: {optionen_gesamt} Kreuze: {kreuze_gesamt}") |
| 291 | print(f"Typen: {dict(Counter(f['typ'] for f in fragen))}") |
| 292 | print(f"Auswahlfragen mit allen Optionen richtig: {len(ist_alle_richtig)} " |
| 293 | f"ohne jede richtige Option: {len(ohne_richtige)}") |
| 294 | print(f"Offene Fragen mit Kernelement-Hervorhebung: {mit_hervorhebung}") |
| 295 | print(f"Fragen mit Bild: {fragen_mit_bild} Bild-Assets: {len(asset_ids)}") |
| 296 | print("=" * 68) |
| 297 | |
| 298 | if warnungen: |
| 299 | print(f"\n{len(warnungen)} WARNUNG(EN):") |
| 300 | for w in warnungen[:25]: |
| 301 | print(f" ! {w}") |
| 302 | if len(warnungen) > 25: |
| 303 | print(f" … und {len(warnungen) - 25} weitere") |
| 304 | |
| 305 | if fehler: |
| 306 | print(f"\n{len(fehler)} FEHLER:") |
| 307 | for e in fehler[:40]: |
| 308 | print(f" X {e}") |
| 309 | if len(fehler) > 40: |
| 310 | print(f" … und {len(fehler) - 40} weitere") |
| 311 | sys.exit(1) |
| 312 | |
| 313 | print("\nAlle harten Prüfungen bestanden.") |