waffensachkunde

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

/ data-pipeline fragen.py

7,8 KB Rohdatei
data-pipeline/fragen.py — 214 Zeilen
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())