waffensachkunde

Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.

/ data-pipeline validate_catalog.py

14,6 KB Rohdatei
data-pipeline/validate_catalog.py — 313 Zeilen
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.")