# -*- coding: utf-8 -*- """Prüft den extrahierten Katalog gegen Sollwerte und Plausibilitätsregeln. Sollwerte stammen aus der unabhängigen Analyse des Originals (siehe PLAN.md, Abschnitt 2.2). Abweichungen sind Fehler, keine Warnungen. """ from __future__ import annotations import io import json import re import sys from collections import Counter from pathlib import Path sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8") OUT = Path(__file__).resolve().parent.parent / "content" / "katalog" data = json.loads((OUT / "katalog.json").read_text(encoding="utf-8")) fragen = data["fragen"] SOLL_ABSCHNITTE = { ("I", "I.1"): (90, 69, 21), ("I", "I.2"): (154, 135, 19), ("I", "I.3"): (35, 29, 6), ("I", "I.4"): (23, 22, 1), ("I", "I.5"): (43, 29, 14), ("II", None): (92, 79, 13), ("III", None): (49, 48, 1), ("IV", None): (89, 60, 29), } SOLL_OPTIONEN = 1430 SOLL_KREUZE = 629 SOLL_MEHRFACH = 114 SOLL_OPT_VERTEILUNG = {2: 10, 3: 445, 4: 10, 5: 2, 6: 3, 7: 1} SOLL_RICHTIG_VERTEILUNG = {1: 357, 2: 80, 3: 27, 4: 5, 5: 1, 6: 1} # Auswahlfragen, bei denen die Vorlage **alle** Optionen ankreuzt. # # Sie fielen beim Vermessen der Ratewahrscheinlichkeit auf (siehe # docs/entscheidung-ratewahrscheinlichkeit.md, Abschnitt 2): Sie allein heben # die mittlere Ratechance des Werkszustands von 0,332 auf 0,3715, also über # 1/3. Der Verdacht lag deshalb nahe, dass hier ein Einleserfehler alles # ankreuzt – so wie 2024 ein Prüfzeichen an der falschen Antwort landete # (docs/stand.md 7.12). # # Gegengeprüft wurde am Original, dreifach: # # 1. Vektorseitig: über alle 1430 Kästchen des PDF liegen in genau 629 # jeweils **zwei** Diagonalsegmente, in den übrigen 801 **keines**. Kein # einziges Kästchen hat eines, drei oder vier – die Erkennung steht vor # einer Lücke, nicht vor einem Schwellwert. # 2. Pixelseitig, unabhängig davon: dieselben Kästchen bei 8-facher # Vergrößerung gerendert und die Tintendichte im Inneren gemessen. Die # 801 haben exakt 0,0000, die 629 zwischen 0,1653 und 0,1978. Null # Widersprüche zur Vektorprüfung. # 3. Mit dem Auge: alle 28 Tabellenzeilen als Bild betrachtet. In jeder sind # die Kreuze tatsächlich gesetzt. # # Es sind durchweg Aufzählungsfragen, bei denen die Vorlage jede Nennung als # richtig führt („Welche Munitionsarten sind vom Waffengesetz erfasst?"). Die # 28 sind also eine Eigenschaft des amtlichen Katalogs, kein Einlesefehler. # # Festgehalten wird deshalb nicht nur die Anzahl, sondern die Menge selbst: # Eine reine Zählung bliebe grün, wenn ein Einlesefehler einer Frage die # Kreuze nähme und einer anderen welche gäbe. Zu jedem Eintrag steht die # amtliche Nummer und die PDF-Seite, auf der er nachzuschlagen ist. SOLL_ALLE_RICHTIG = { "I.1-02": ("1.02", 6), "I.1-11": ("1.11", 7), "I.1-31": ("1.31", 11), "I.1-86": ("1.86", 23), "I.2-38": ("2.38", 33), "I.2-41": ("2.41", 34), "I.2-59": ("2.59", 38), "I.2-96": ("2.96", 46), "I.2-147": ("2.147", 58), "I.2-152": ("2.152", 58), "I.3-04": ("3.04", 60), "I.3-27": ("3.27", 65), "I.3-31": ("3.31", 66), "I.3-33": ("3.33", 66), "I.5-06": ("5.06", 73), "I.5-38": ("5.38", 79), "II-13": ("13", 82), "II-33": ("33", 86), "II-75": ("75", 93), "III-11": ("11", 100), "III-30": ("30", 103), "III-46": ("46", 107), "IV-04": ("04", 108), "IV-08": ("08", 109), "IV-15": ("15", 110), "IV-32": ("32", 114), "IV-44": ("44", 116), "IV-46": ("46", 116), } fehler: list[str] = [] warnungen: list[str] = [] def pruefe(bedingung: bool, meldung: str, hart: bool = True) -> None: if not bedingung: (fehler if hart else warnungen).append(meldung) # ---------------------------------------------------------- Struktur & Zahlen pruefe(len(fragen) == 575, f"Fragenzahl {len(fragen)}, erwartet 575") je_abschnitt: Counter = Counter() je_abschnitt_typ: Counter = Counter() for f in fragen: key = (f["kapitel"], f["abschnitt"]) je_abschnitt[key] += 1 je_abschnitt_typ[(key, "mc" if f["typ"] == "mc" else "offen")] += 1 for key, (ges, mc, offen) in SOLL_ABSCHNITTE.items(): ist_ges = je_abschnitt[key] ist_mc = je_abschnitt_typ[(key, "mc")] ist_offen = je_abschnitt_typ[(key, "offen")] label = key[1] or f"Kapitel {key[0]}" pruefe(ist_ges == ges, f"{label}: {ist_ges} Fragen, erwartet {ges}") pruefe(ist_mc == mc, f"{label}: {ist_mc} MC-Fragen, erwartet {mc}") pruefe(ist_offen == offen, f"{label}: {ist_offen} offene Fragen, erwartet {offen}") # ------------------------------------------------------- Optionen und Kreuze optionen_gesamt = sum(len(f.get("optionen", [])) for f in fragen) kreuze_gesamt = sum(sum(1 for o in f.get("optionen", []) if o["korrekt"]) for f in fragen) mehrfach = sum(1 for f in fragen if sum(1 for o in f.get("optionen", []) if o["korrekt"]) > 1) pruefe(optionen_gesamt == SOLL_OPTIONEN, f"{optionen_gesamt} Optionen, erwartet {SOLL_OPTIONEN}") pruefe(kreuze_gesamt == SOLL_KREUZE, f"{kreuze_gesamt} Kreuze, erwartet {SOLL_KREUZE}") pruefe(mehrfach == SOLL_MEHRFACH, f"{mehrfach} Mehrfachauswahl-Fragen, erwartet {SOLL_MEHRFACH}") opt_vert = Counter(len(f["optionen"]) for f in fragen if f["typ"] == "mc") richtig_vert = Counter(sum(1 for o in f["optionen"] if o["korrekt"]) for f in fragen if f["typ"] == "mc") pruefe(dict(opt_vert) == SOLL_OPT_VERTEILUNG, f"Optionsanzahl-Verteilung {dict(sorted(opt_vert.items()))}, erwartet {SOLL_OPT_VERTEILUNG}") pruefe(dict(richtig_vert) == SOLL_RICHTIG_VERTEILUNG, f"Richtig-Verteilung {dict(sorted(richtig_vert.items()))}, erwartet {SOLL_RICHTIG_VERTEILUNG}") # ------------------------------- Beide Randfälle der Kreuzerkennung festnageln # Die Kreuze sind das Einzige am Katalog, was die Anwendung als *Wahrheit* # lehrt. Ihre beiden Ausreißerfälle sind zugleich die beiden Fehlerbilder eines # Einlesefehlers, und sie sind hier bewusst getrennt festgehalten: # # alles angekreuzt – der Einleser hätte ein Layout nicht erkannt und # vorsichtshalber jedes Kästchen genommen. Sollwert: die # 28 Fragen aus SOLL_ALLE_RICHTIG, am Original belegt. # nichts angekreuzt – der Einleser hätte die Kreuze verloren. Sollwert: keine # einzige Frage. Die Frage darunter (Zeile „MC-Frage ohne # richtige Antwort") nennt den Einzelfall beim Namen; # diese Zeile hält fest, dass es die Fehlerart im ganzen # Katalog nicht gibt. ist_alle_richtig = { f["id"]: (f["amtliche_nummer"], f["seite"]) for f in fragen if f["typ"] == "mc" and f["optionen"] and all(o["korrekt"] for o in f["optionen"]) } if ist_alle_richtig != SOLL_ALLE_RICHTIG: neu = sorted(set(ist_alle_richtig) - set(SOLL_ALLE_RICHTIG)) weg = sorted(set(SOLL_ALLE_RICHTIG) - set(ist_alle_richtig)) verschoben = sorted(k for k in set(ist_alle_richtig) & set(SOLL_ALLE_RICHTIG) if ist_alle_richtig[k] != SOLL_ALLE_RICHTIG[k]) pruefe(False, f"Fragen mit allen Optionen richtig weichen ab " f"({len(ist_alle_richtig)} gefunden, {len(SOLL_ALLE_RICHTIG)} erwartet) – " f"neu {neu}, entfallen {weg}, Nummer/Seite geändert {verschoben}") ohne_richtige = sorted(f["id"] for f in fragen if f["typ"] == "mc" and not any(o["korrekt"] for o in f["optionen"])) pruefe(not ohne_richtige, f"Auswahlfragen ohne jede richtige Option: {len(ohne_richtige)} – {ohne_richtige[:10]}") # ------------------------------------------------------ Inhaltliche Regeln ids = [f["id"] for f in fragen] pruefe(len(set(ids)) == len(ids), "Doppelte Fragen-IDs vorhanden") for f in fragen: fid = f["id"] frage_text = f["frage"]["text"] pruefe(bool(frage_text.strip()), f"{fid}: leerer Fragetext") pruefe(len(frage_text) >= 8, f"{fid}: verdächtig kurzer Fragetext: {frage_text!r}", hart=False) pruefe(" " not in frage_text, f"{fid}: doppelte Leerzeichen im Fragetext", hart=False) if f["typ"] == "mc": korrekt = [o for o in f["optionen"] if o["korrekt"]] pruefe(len(korrekt) >= 1, f"{fid}: MC-Frage ohne richtige Antwort") pruefe(len(f["optionen"]) >= 2, f"{fid}: MC-Frage mit < 2 Optionen") for o in f["optionen"]: otext = o["inhalt"]["text"] pruefe(bool(otext.strip() or o["bilder"]), f"{fid} Option {o['label']}: leer") pruefe(not re.match(r"^[a-h]\)", otext.strip()), f"{fid} Option {o['label']}: Label nicht abgetrennt: {otext[:30]!r}") labels = [o["label"] for o in f["optionen"]] pruefe(labels == sorted(labels), f"{fid}: Optionsreihenfolge {labels}") else: pruefe(bool(f["musterantwort"]["text"].strip()), f"{fid}: leere Musterantwort") pruefe("warnungen" not in f, f"{fid}: Parser-Warnung {f.get('warnungen')}", hart=False) # --------------------------------------------------- Hervorhebungen prüfen # Wörter, die allein nie ein Kernelement sein können. Bewusst knapp gehalten: # Die Liste soll Bruchstücke erkennen, nicht Inhalte bewerten. FUNKTIONSWOERTER = frozenset(""" der die das den dem des ein eine einer eines einem einen und oder aber auch als am an auf aus bei bis durch für gegen im in ist sind war waren mit nach ohne seit so um von vor zu zum zur über werden wird wurde wurden kann können darf dürfen muss müssen soll sollen nicht kein keine nur noch wenn dass es sie er ihm ihn ihr sich man dann wie wo was welche welcher welches diese dieser dieses hat haben hatte sein seine seiner seines sowie damit dabei dazu darauf """.split()) mit_hervorhebung = sum( 1 for f in fragen if f["typ"] != "mc" and any(s.get("h") for s in f["musterantwort"]["segmente"])) pruefe(35 <= mit_hervorhebung <= 55, f"{mit_hervorhebung} offene Fragen mit hervorgehobenen Kernelementen, erwartet ~43", hart=False) # Diese Zählung war bis 0.17.0 die einzige Prüfung, die `h` überhaupt anfasste. # Sie war grün, während 14 Listen zerrissene Läufe enthielten und die # Anwendung unter „Diese Kernelemente muss Ihre Antwort enthalten" als ersten # Punkt das Wort „zum" zeigte. Eine Zählung sagt nichts über den Inhalt. # # Die beiden folgenden Prüfungen sind hart, weil ein Rückfall genau so # aussähe wie damals: Der Katalog baut durch, die Zahl stimmt, und der Fehler # steht nur in der Anzeige. Gegengeprüft am Katalog vor der Behebung: 22 # Fehler, genau die 18 zerrissenen Läufe und die 4 Funktionswort-Einträge. # # NICHT erkennbar ist hier die andere Fehlerart – ein Wort, das die # Unterstreichungserkennung gar nicht erst erfasst hat (Frage IV-36, # „Unterklasse P2"). Dieser Verlust entsteht vor der Segmentbildung und # hinterlässt in der Ausgabe keine Spur. Dagegen hilft nur die Schwelle # `UNDERLINE_MIN_OVERLAP` in parse_catalog.py, nicht eine Prüfung hier. for f in fragen: if f["typ"] == "mc": continue fid = f["id"] segmente = f["musterantwort"]["segmente"] # 1. Kein markierter Lauf darf unmittelbar an einen anderen grenzen – # dazwischen stünde sonst nur der Zeilenumbruch, der ihn zerriss. for i in range(len(segmente) - 1): if segmente[i].get("h") and segmente[i + 1].get("h"): pruefe(False, f"{fid}: zwei markierte Läufe grenzen aneinander " f"({segmente[i]['t'][-20:]!r} | {segmente[i+1]['t'][:20]!r})") for i in range(len(segmente) - 2): if (segmente[i].get("h") and segmente[i + 2].get("h") and not segmente[i + 1].get("h") and not segmente[i + 1]["t"].strip()): pruefe(False, f"{fid}: markierter Lauf am Zeilenumbruch zerrissen " f"({segmente[i]['t'][-20:]!r} … {segmente[i+2]['t'][:20]!r})") # 2. Kein Kernelement darf ausschließlich aus Funktionswörtern bestehen. # Genau so sahen die Bruchstücke aus: „zum", „sind", „nicht". for seg in segmente: if not seg.get("h"): continue wortlaut = seg["t"].strip() if not wortlaut: continue woerter = [w for w in re.findall(r"[^\W\d_]+", wortlaut, re.UNICODE)] pruefe(bool(woerter) and not all(w.lower() in FUNKTIONSWOERTER for w in woerter), f"{fid}: Kernelement besteht nur aus Funktionswörtern: {wortlaut!r}") # ------------------------------------------------------------ Bildzuordnung fragen_mit_bild = sum(1 for f in fragen if f["bilder"] or any(o.get("bilder") for o in f.get("optionen", []))) pruefe(fragen_mit_bild == 23, f"{fragen_mit_bild} Fragen mit Bild, erwartet 23") asset_ids = {b["id"] for b in data["bilder"]} for f in fragen: for bid in f["bilder"] + [b for o in f.get("optionen", []) for b in o.get("bilder", [])]: pruefe(bid in asset_ids, f"{f['id']}: unbekannte Bild-ID {bid}") pruefe((OUT / "assets" / f"{bid}.png").exists(), f"{f['id']}: Bilddatei {bid}.png fehlt") # Barrierefreiheit: jede Abbildung braucht einen Alternativtext (WCAG 1.1.1) for b in data["bilder"]: alt = (b.get("alt") or "").strip() pruefe(bool(alt), f"Bild {b['id']}: Alternativtext fehlt") pruefe(len(alt) >= 15, f"Bild {b['id']}: Alternativtext zu knapp: {alt!r}", hart=False) # Zweite Stufe des Bildkonzepts (Prüfplan S4): die erklärende Beschreibung, # die der Lernmodus nach dem Antworten nachreicht. Weich, nicht hart: # Eine fehlende Beschreibung lässt die Anwendung funktionieren – sie soll # aber sichtbar bleiben, bis ein Mensch sie aus belegbaren Quellen ergänzt. beschreibung = (b.get("beschreibung") or "").strip() pruefe(bool(beschreibung), f"Bild {b['id']}: erklärende Beschreibung fehlt", hart=False) # ------------------------------------------------------------------ Ausgabe print("=" * 68) print(f"Fragen: {len(fragen)} Optionen: {optionen_gesamt} Kreuze: {kreuze_gesamt}") print(f"Typen: {dict(Counter(f['typ'] for f in fragen))}") print(f"Auswahlfragen mit allen Optionen richtig: {len(ist_alle_richtig)} " f"ohne jede richtige Option: {len(ohne_richtige)}") print(f"Offene Fragen mit Kernelement-Hervorhebung: {mit_hervorhebung}") print(f"Fragen mit Bild: {fragen_mit_bild} Bild-Assets: {len(asset_ids)}") print("=" * 68) if warnungen: print(f"\n{len(warnungen)} WARNUNG(EN):") for w in warnungen[:25]: print(f" ! {w}") if len(warnungen) > 25: print(f" … und {len(warnungen) - 25} weitere") if fehler: print(f"\n{len(fehler)} FEHLER:") for e in fehler[:40]: print(f" X {e}") if len(fehler) > 40: print(f" … und {len(fehler) - 40} weitere") sys.exit(1) print("\nAlle harten Prüfungen bestanden.")