waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
/ data-pipeline fragen.py
| 1 | """Gibt Fragen des amtlichen Katalogs lesbar aus – mit Lösung. |
| 2 | |
| 3 | Gedacht für die Arbeit an den Erklärungstexten: Man braucht die Frage, die |
| 4 | markierten richtigen Antworten und – bei offenen Fragen – die Musterantwort |
| 5 | des Bundesverwaltungsamtes an einer Stelle. |
| 6 | |
| 7 | Aufrufe |
| 8 | ------- |
| 9 | Ein Abschnitt, ausschnittweise:: |
| 10 | |
| 11 | python data-pipeline/fragen.py --abschnitt I.1 --von 0 --anzahl 10 |
| 12 | |
| 13 | Ein ganzes Kapitel ohne Abschnittsgliederung:: |
| 14 | |
| 15 | python data-pipeline/fragen.py --kapitel II --von 0 --anzahl 10 |
| 16 | |
| 17 | Einzelne Fragen:: |
| 18 | |
| 19 | python data-pipeline/fragen.py --id I.1-04 I.2-01 |
| 20 | |
| 21 | Nur die Kennungen (etwa zum Aufteilen in Arbeitspakete):: |
| 22 | |
| 23 | python data-pipeline/fragen.py --abschnitt I.1 --nur-ids |
| 24 | |
| 25 | Wortformen im Katalog zählen – für die Arbeit am Glossar:: |
| 26 | |
| 27 | python data-pipeline/fragen.py --wortform führt geführt Führens |
| 28 | |
| 29 | Zeigt zusätzlich an, ob zu einer Frage bereits eine Erklärung vorliegt. |
| 30 | """ |
| 31 | |
| 32 | from __future__ import annotations |
| 33 | |
| 34 | import argparse |
| 35 | import io |
| 36 | import json |
| 37 | import re |
| 38 | import sys |
| 39 | from pathlib import Path |
| 40 | |
| 41 | WURZEL = Path(__file__).resolve().parent.parent |
| 42 | KATALOG = WURZEL / 'content' / 'katalog' / 'katalog.json' |
| 43 | ERKLAERUNGEN = WURZEL / 'content' / 'erklaerungen.json' |
| 44 | ALTTEXTE = WURZEL / 'content' / 'alttexte.json' |
| 45 | |
| 46 | |
| 47 | def vorhandene_erklaerungen() -> set[str]: |
| 48 | if not ERKLAERUNGEN.exists(): |
| 49 | return set() |
| 50 | daten = json.load(io.open(ERKLAERUNGEN, encoding='utf-8')) |
| 51 | return set(daten.get('zuFrage') or {}) |
| 52 | |
| 53 | |
| 54 | def alttexte() -> dict[str, str]: |
| 55 | """Bild-ID -> Beschreibung. |
| 56 | |
| 57 | Die Datei bildet Bild-ID auf `{alt, beschreibung, hinweis}` ab. Fuer die |
| 58 | Arbeit an den Erklaerungen ist alles drei nuetzlich: `alt` ist der |
| 59 | ausgelieferte Alternativtext, `beschreibung` die erklaerende Bedeutung, |
| 60 | die die Anwendung erst nach dem Beantworten nachreicht, `hinweis` die |
| 61 | interne Notiz zur Herkunft der Formulierung. Bei den Fragen, in denen die |
| 62 | Zeichen selbst die Antwortoptionen sind, verschweigt `alt` die Bedeutung |
| 63 | bewusst - dann tragen sie die beiden anderen Felder. |
| 64 | """ |
| 65 | if not ALTTEXTE.exists(): |
| 66 | return {} |
| 67 | daten = json.load(io.open(ALTTEXTE, encoding='utf-8')) |
| 68 | ergebnis: dict[str, str] = {} |
| 69 | for schluessel, wert in daten.items(): |
| 70 | if schluessel.startswith('_'): |
| 71 | continue # Kopfzeilen der Datei, kein Bild |
| 72 | if isinstance(wert, str): |
| 73 | ergebnis[schluessel] = wert |
| 74 | else: |
| 75 | teile = [wert.get('alt', '')] |
| 76 | if wert.get('beschreibung'): |
| 77 | teile.append(f'Bedeutung: {wert["beschreibung"]}') |
| 78 | if wert.get('hinweis'): |
| 79 | teile.append(f'intern: {wert["hinweis"]}') |
| 80 | ergebnis[schluessel] = ' | '.join(t for t in teile if t) |
| 81 | return ergebnis |
| 82 | |
| 83 | |
| 84 | #: Zeichen, die in einem deutschen Wort vorkommen – wie in shared/glossar.ts. |
| 85 | #: `` taugt nicht: Es kennt nur ASCII und saehe in „Schiessstaette" an jedem |
| 86 | #: Umlaut eine Wortgrenze. |
| 87 | WORTZEICHEN = 'A-Za-zÄÖÜäöüßẞ0-9' |
| 88 | |
| 89 | |
| 90 | def katalogkorpus(katalog: dict) -> str: |
| 91 | """Fragen, Antwortmoeglichkeiten und Musterantworten als ein Text.""" |
| 92 | teile: list[str] = [] |
| 93 | for frage in katalog['fragen']: |
| 94 | teile.append(frage['frage']['text']) |
| 95 | muster = frage.get('musterantwort') |
| 96 | if muster: |
| 97 | teile.append(muster['text']) |
| 98 | for option in frage.get('optionen') or []: |
| 99 | teile.append(option['inhalt']['text']) |
| 100 | return ' '.join(teile) |
| 101 | |
| 102 | |
| 103 | def erklaerungskorpus() -> str: |
| 104 | """Die Erklaerungstexte als ein Text. |
| 105 | |
| 106 | Abkuerzungen wie WBK, NWR oder VDMA kommen im amtlichen Katalog gar nicht |
| 107 | vor - sehr wohl aber in den Erklaerungen, die die Anwendung anzeigt. Fuer |
| 108 | WCAG 3.1.4 zaehlt, was der Nutzer liest, nicht nur der Fragenwortlaut. |
| 109 | """ |
| 110 | if not ERKLAERUNGEN.exists(): |
| 111 | return '' |
| 112 | daten = json.load(io.open(ERKLAERUNGEN, encoding='utf-8')) |
| 113 | teile: list[str] = [] |
| 114 | for eintrag in (daten.get('zuFrage') or {}).values(): |
| 115 | teile.append(eintrag.get('kurz', '')) |
| 116 | teile.append(eintrag.get('text', '')) |
| 117 | teile.append(eintrag.get('merksatz', '') or '') |
| 118 | return ' '.join(teile) |
| 119 | |
| 120 | |
| 121 | def wortform_suchen(formen: list[str], katalog: dict) -> int: |
| 122 | """Zaehlt, wie oft Wortformen als eigenes Wort vorkommen. |
| 123 | |
| 124 | Durchsucht werden Katalog UND Erklaerungen. Gesucht wird mit derselben |
| 125 | Wortgrenze wie in der Anwendung. |
| 126 | """ |
| 127 | korpus = katalogkorpus(katalog) + ' ' + erklaerungskorpus() |
| 128 | |
| 129 | for form in formen: |
| 130 | ausdruck = rf'(?<![{WORTZEICHEN}]){re.escape(form)}(?![{WORTZEICHEN}])' |
| 131 | treffer = list(re.finditer(ausdruck, korpus, re.IGNORECASE)) |
| 132 | print(f'{len(treffer):5} {form!r}') |
| 133 | for fund in treffer[:2]: |
| 134 | anfang = max(0, fund.start() - 70) |
| 135 | print(f' …{korpus[anfang:fund.end() + 70]}…') |
| 136 | return 0 |
| 137 | |
| 138 | |
| 139 | def frage_zeigen(frage: dict, bilder: dict[str, str], schon_da: set[str]) -> None: |
| 140 | marke = ' [Erklärung liegt vor]' if frage['id'] in schon_da else '' |
| 141 | ort = frage['kapitel'] + (f' / {frage["abschnitt"]}' if frage.get('abschnitt') else '') |
| 142 | print(f'### {frage["id"]} (amtliche Nr. {frage["amtliche_nummer"]}, {ort}, ' |
| 143 | f'Typ {frage["typ"]}, Katalogseite {frage["seite"]}){marke}') |
| 144 | print(f'FRAGE: {frage["frage"]["text"]}') |
| 145 | |
| 146 | for bild_id in frage.get('bilder') or []: |
| 147 | beschreibung = bilder.get(bild_id, '(kein Alternativtext hinterlegt)') |
| 148 | print(f' [Abbildung zur Frage – {bild_id}: {beschreibung}]') |
| 149 | |
| 150 | if frage['typ'] == 'freitext': |
| 151 | muster = (frage.get('musterantwort') or {}).get('text', '') |
| 152 | print(f'MUSTERANTWORT (amtlich): {muster}') |
| 153 | else: |
| 154 | for option in frage.get('optionen') or []: |
| 155 | marke_o = 'RICHTIG' if option['korrekt'] else 'falsch ' |
| 156 | print(f' [{marke_o}] {option["label"]}) {option["inhalt"]["text"]}') |
| 157 | for bild_id in option.get('bilder') or []: |
| 158 | beschreibung = bilder.get(bild_id, '(kein Alternativtext hinterlegt)') |
| 159 | print(f' [Abbildung – {bild_id}: {beschreibung}]') |
| 160 | print() |
| 161 | |
| 162 | |
| 163 | def main() -> int: |
| 164 | zerleger = argparse.ArgumentParser(description='Fragen des amtlichen Katalogs ausgeben.') |
| 165 | zerleger.add_argument('--kapitel') |
| 166 | zerleger.add_argument('--abschnitt') |
| 167 | zerleger.add_argument('--id', nargs='+') |
| 168 | zerleger.add_argument('--von', type=int, default=0) |
| 169 | zerleger.add_argument('--anzahl', type=int) |
| 170 | zerleger.add_argument('--nur-ids', action='store_true') |
| 171 | zerleger.add_argument('--ohne-erklaerung', action='store_true', |
| 172 | help='Nur Fragen, zu denen noch keine Erklärung vorliegt') |
| 173 | zerleger.add_argument('--wortform', nargs='+', |
| 174 | help='Zählt Vorkommen dieser Wortformen im Katalog (für das Glossar)') |
| 175 | argumente = zerleger.parse_args() |
| 176 | |
| 177 | katalog = json.load(io.open(KATALOG, encoding='utf-8')) |
| 178 | |
| 179 | if argumente.wortform: |
| 180 | return wortform_suchen(argumente.wortform, katalog) |
| 181 | schon_da = vorhandene_erklaerungen() |
| 182 | bilder = alttexte() |
| 183 | |
| 184 | fragen = katalog['fragen'] |
| 185 | if argumente.id: |
| 186 | gesucht = set(argumente.id) |
| 187 | fragen = [f for f in fragen if f['id'] in gesucht] |
| 188 | else: |
| 189 | if argumente.kapitel: |
| 190 | fragen = [f for f in fragen if f['kapitel'] == argumente.kapitel] |
| 191 | if argumente.abschnitt: |
| 192 | fragen = [f for f in fragen if f.get('abschnitt') == argumente.abschnitt] |
| 193 | |
| 194 | if argumente.ohne_erklaerung: |
| 195 | fragen = [f for f in fragen if f['id'] not in schon_da] |
| 196 | |
| 197 | fragen = fragen[argumente.von :] |
| 198 | if argumente.anzahl is not None: |
| 199 | fragen = fragen[: argumente.anzahl] |
| 200 | |
| 201 | if argumente.nur_ids: |
| 202 | for frage in fragen: |
| 203 | print(frage['id']) |
| 204 | return 0 |
| 205 | |
| 206 | print(f'# {len(fragen)} Fragen\n') |
| 207 | for frage in fragen: |
| 208 | frage_zeigen(frage, bilder, schon_da) |
| 209 | return 0 |
| 210 | |
| 211 | |
| 212 | if __name__ == '__main__': |
| 213 | sys.stdout.reconfigure(encoding='utf-8', errors='replace') # type: ignore[union-attr] |
| 214 | raise SystemExit(main()) |