waffensachkunde

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

/ data-pipeline themen_bauen.py

14,4 KB Rohdatei
data-pipeline/themen_bauen.py — 397 Zeilen
1 """Baut ``content/themen.json`` – die Feingliederung der Kapitel II bis IV.
2
3 Warum es diese Datei gibt
4 -------------------------
5 Der amtliche Katalog gliedert **nur Kapitel I** in Abschnitte. Für 230 Fragen
6 aus den Kapiteln II, III und IV steht ``abschnitt`` auf ``null`` – und alles,
7 was auf Bereichen aufsetzt (Kapitelwahl, Lernstand je Bereich, Themenanalyse
8 der Prüfungsauswertung, Fragenlisten-Druck), arbeitet für sie zwangsläufig auf
9 Kapitelebene. Wer in der Simulation an Munitionskunde scheitert, sieht nur
10 „Kapitel II schwach“ und kann nur das ganze Kapitel nachüben: 92 Fragen.
11
12 Diese Gliederung ist **redaktionell**, nicht amtlich. Sie steht deshalb in
13 einer eigenen Datei und nicht im Katalog – genau wie Erklärungen und Glossar.
14 Der amtliche Katalog bleibt unangetastet.
15
16 Was das Skript garantiert
17 -------------------------
18 1. **Vollständigkeit.** Jede Frage der Kapitel II bis IV liegt in genau einer
19 Gruppe. Eine unvollständige Gliederung wäre schlimmer als keine: Themen zu
20 üben hieße dann, Fragen stillschweigend zu übergehen.
21 2. **Keine Doppelung.** Keine Frage steht in zwei Gruppen.
22 3. **Kapiteltreue.** Keine Gruppe mischt Kapitel.
23 4. **Mindestgröße.** Eine Gruppe mit einer Frage ist kein Thema.
24
25 Scheitert eine dieser Prüfungen, wird nichts geschrieben.
26
27 Aufruf
28 ------
29 python data-pipeline/themen_bauen.py
30 python data-pipeline/themen_bauen.py --selbsttest
31 """
32
33 from __future__ import annotations
34
35 import io
36 import json
37 import re
38 import sys
39 from datetime import date
40 from pathlib import Path
41
42 WURZEL = Path(__file__).resolve().parent.parent
43 KATALOG = WURZEL / 'content' / 'katalog' / 'katalog.json'
44 ZIEL = WURZEL / 'content' / 'themen.json'
45
46 #: Kapitel ohne amtliche Abschnittsgliederung. Kapitel I bleibt außen vor –
47 #: dort gibt es die Abschnitte des Bundesverwaltungsamtes, und eine zweite
48 #: Gliederung daneben wäre eine Zahl, die auseinanderläuft.
49 KAPITEL = ('II', 'III', 'IV')
50
51 #: Weniger als so viele Fragen sind kein Thema, sondern ein Einzelfall.
52 MINDESTGROESSE = 3
53
54 HINWEIS = (
55 'Redaktionelle Feingliederung dieser Software, nicht Teil des amtlichen '
56 'Fragenkatalogs des Bundesverwaltungsamtes. Der amtliche Katalog gliedert nur '
57 'Kapitel I in Abschnitte; fuer die 230 Fragen der Kapitel II bis IV gibt es dort '
58 'keine Untergliederung. Nicht von Hand aendern - neu erzeugen mit: '
59 'python data-pipeline/themen_bauen.py'
60 )
61
62
63 def n(kapitel: str, *nummern: int) -> list[str]:
64 """Fragen-IDs aus Nummern – die Kapitel II bis IV zählen zweistellig."""
65 return [f'{kapitel}-{nummer:02d}' for nummer in nummern]
66
67
68 #: Die Gliederung. Von Hand geschrieben und bewusst grob: 11 Gruppen für die
69 #: 92 Fragen des Kapitels II sind lernbare Portionen; dreißig wären ein zweites
70 #: Inhaltsverzeichnis. Über die Kapitel II bis IV sind es 29 Gruppen für 230
71 #: Fragen. Beide Zahlen rechnet der Selbsttest nach: Bis Fassung 0.27.2 stand
72 #: hier „Zwölf Gruppen für 92 Fragen“, und die Zahl war seit dem Einführungs-
73 #: Commit um eins daneben.
74 GRUPPEN: list[dict] = [
75 # ── Kapitel II: Waffentechnik ─────────────────────────────────────────
76 {
77 'id': 'II.1',
78 'titel': 'Waffenarten und Funktionsprinzipien',
79 'fragen': n('II', 1, 2, 3, 4, 6, 7, 8, 9, 11, 12, 16, 18, 19, 45, 46, 47, 56, 58),
80 },
81 {
82 'id': 'II.2',
83 'titel': 'Wesentliche Teile und Baugruppen',
84 'fragen': n('II', 5, 13, 14, 15, 55, 88, 89),
85 },
86 {
87 'id': 'II.3',
88 'titel': 'Lauf, Züge und Felder, Zubehör',
89 'fragen': n('II', 10, 37, 38, 39, 49, 50, 51, 57),
90 },
91 {
92 'id': 'II.4',
93 'titel': 'Schalldämpfer und Schallentstehung',
94 'fragen': n('II', 33, 34, 35, 36, 86, 87),
95 },
96 {'id': 'II.5', 'titel': 'Sicherungen', 'fragen': n('II', 52, 53, 54)},
97 {
98 'id': 'II.6',
99 'titel': 'Munition: Aufbau und Zündung',
100 'fragen': n('II', 20, 21, 22, 23, 25, 26, 48),
101 },
102 {
103 'id': 'II.7',
104 'titel': 'Kaliber und Munitionsbezeichnung',
105 'fragen': n('II', 17, 24, 27, 28, 29, 30, 31, 32, 40, 41, 42, 43, 44),
106 },
107 {
108 'id': 'II.8',
109 'titel': 'Ballistik: Begriffe und Teilgebiete',
110 'fragen': n('II', 60, 61, 62, 74, 75, 76, 79, 80, 85),
111 },
112 {
113 'id': 'II.9',
114 'titel': 'Flugbahn, Drall und Streuung',
115 'fragen': n('II', 59, 77, 78, 81, 82, 83, 84),
116 },
117 {
118 'id': 'II.10',
119 'titel': 'Reichweite und Gefährdungsbereich',
120 'fragen': n('II', 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73),
121 },
122 {'id': 'II.11', 'titel': 'Unbrauchbarmachung', 'fragen': n('II', 90, 91, 92)},
123 # ── Kapitel III: Handhabung von Schusswaffen ──────────────────────────
124 {
125 'id': 'III.1',
126 'titel': 'Grundregeln im Umgang mit Schusswaffen',
127 'fragen': n('III', 1, 8, 46),
128 },
129 {
130 'id': 'III.2',
131 'titel': 'Übergeben und Annehmen',
132 'fragen': n('III', 2, 9, 10, 13, 20),
133 },
134 {
135 'id': 'III.3',
136 'titel': 'Laden, Entladen und Schussbereitschaft',
137 'fragen': n('III', 11, 12, 27, 37, 38, 39, 40, 43, 45),
138 },
139 {
140 'id': 'III.4',
141 'titel': 'Ablegen und Tragen auf dem Stand',
142 'fragen': n('III', 18, 19, 21, 26, 33, 42, 44),
143 },
144 {
145 'id': 'III.5',
146 'titel': 'Schießstätte: Ordnung, Aufsicht und Gefahrenbereich',
147 'fragen': n('III', 15, 16, 17, 22, 23, 24, 25, 30, 31, 32, 34, 35, 48, 49),
148 },
149 {
150 'id': 'III.6',
151 'titel': 'Schießen außerhalb von Schießstätten',
152 'fragen': n('III', 3, 14, 28, 29),
153 },
154 {
155 'id': 'III.7',
156 'titel': 'Munition auf der Schießstätte',
157 'fragen': n('III', 4, 5, 6, 7, 36, 41, 47),
158 },
159 # ── Kapitel IV: Not- und Seenotsignalmittel ───────────────────────────
160 {
161 'id': 'IV.1',
162 'titel': 'Begriffe und Arten der Signalmittel',
163 'fragen': n('IV', 1, 2, 3, 9, 41, 42),
164 },
165 {
166 'id': 'IV.2',
167 'titel': 'Verwendung im Notfall',
168 'fragen': n('IV', 4, 6, 7, 8, 10, 11, 12, 13, 43, 44, 45, 53, 59, 83),
169 },
170 {
171 'id': 'IV.3',
172 'titel': 'Technische Daten und Aufbau',
173 'fragen': n('IV', 5, 48, 51, 52, 55, 57, 58),
174 },
175 {
176 'id': 'IV.4',
177 'titel': 'Kennzeichnung, Zulassung und Klassen',
178 'fragen': n('IV', 39, 40, 47, 49, 50, 54, 56, 89),
179 },
180 {
181 'id': 'IV.5',
182 'titel': 'Haltbarkeit, Lagerung und Entsorgung',
183 'fragen': n('IV', 14, 15, 16, 17, 18, 19, 25, 26, 46),
184 },
185 {
186 'id': 'IV.6',
187 'titel': 'Aufbewahrung und Transport',
188 'fragen': n('IV', 20, 21, 22, 23, 24, 75, 76, 77),
189 },
190 {
191 'id': 'IV.7',
192 'titel': 'Erlaubnisse, Bedürfnis und Dokumente',
193 'fragen': n('IV', 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 80, 85, 86, 87, 88),
194 },
195 {'id': 'IV.8', 'titel': 'Überlassen und Führen', 'fragen': n('IV', 78, 79, 82)},
196 {
197 'id': 'IV.9',
198 'titel': 'Rechtsgrundlagen und Zuständigkeiten',
199 'fragen': n('IV', 61, 63, 64, 65, 66, 67, 68, 69, 70, 71),
200 },
201 {'id': 'IV.10', 'titel': 'Störungen und Versager', 'fragen': n('IV', 72, 73, 74)},
202 {
203 'id': 'IV.11',
204 'titel': 'Missbrauch und Meldepflichten',
205 'fragen': n('IV', 60, 62, 81, 84),
206 },
207 ]
208
209
210 def pruefen(gruppen: list[dict], fragen: dict[str, dict]) -> list[str]:
211 """Alle Zusagen dieser Datei. Leere Liste heißt: in Ordnung."""
212 befunde: list[str] = []
213
214 kennungen = [gruppe['id'] for gruppe in gruppen]
215 if len(set(kennungen)) != len(kennungen):
216 befunde.append('Doppelte Gruppenkennung')
217
218 zugeordnet: dict[str, str] = {}
219 for gruppe in gruppen:
220 if not gruppe['titel'].strip():
221 befunde.append(f'{gruppe["id"]}: Titel fehlt')
222 if len(gruppe['fragen']) < MINDESTGROESSE:
223 befunde.append(
224 f'{gruppe["id"]}: nur {len(gruppe["fragen"])} Fragen '
225 f'(mindestens {MINDESTGROESSE})'
226 )
227
228 kapitel_der_gruppe = set()
229 for frage_id in gruppe['fragen']:
230 frage = fragen.get(frage_id)
231 if frage is None:
232 befunde.append(f'{gruppe["id"]}: {frage_id} gibt es im Katalog nicht')
233 continue
234 kapitel_der_gruppe.add(frage['kapitel'])
235 if frage_id in zugeordnet:
236 befunde.append(
237 f'{frage_id} steht in {zugeordnet[frage_id]} und in {gruppe["id"]}'
238 )
239 zugeordnet[frage_id] = gruppe['id']
240
241 if len(kapitel_der_gruppe) > 1:
242 befunde.append(f'{gruppe["id"]}: mischt Kapitel {sorted(kapitel_der_gruppe)}')
243
244 # Die wichtigste Zusage: Vollständigkeit. Eine luecken­hafte Gliederung
245 # hiesse, dass „Thema ueben" Fragen stillschweigend uebergeht.
246 for frage_id, frage in fragen.items():
247 if frage['kapitel'] in KAPITEL and frage_id not in zugeordnet:
248 befunde.append(f'{frage_id} ({frage["kapitel"]}) liegt in keiner Gruppe')
249 if frage['kapitel'] not in KAPITEL and frage_id in zugeordnet:
250 befunde.append(f'{frage_id} gehoert zu Kapitel I und braucht keine Gruppe')
251
252 return befunde
253
254
255 def gliederungszahlen_pruefen() -> int:
256 """Die Zahlen im Kommentar über :data:`GRUPPEN` gegen die Gliederung.
257
258 Der Kommentar begründet die gewählte Feinheit – und ist die einzige
259 Stelle, an der sie begründet wird. Er nannte „Zwölf Gruppen für 92
260 Fragen“; gezählt sind es elf Gruppen für die 92 Fragen des Kapitels II
261 und 29 Gruppen für 230 Fragen insgesamt. Kein Programmverhalten hing
262 daran, wohl aber die Nachvollziehbarkeit: Wer die Feinheit beurteilen
263 will, zählt nach und findet etwas anderes.
264 """
265 quelle = io.open(__file__, encoding='utf-8').read()
266 kopf = quelle[: quelle.index('GRUPPEN: list[dict]')]
267 kopf = ' '.join(
268 zeile[3:] for zeile in kopf.splitlines() if zeile.startswith('#: ')
269 )
270
271 kapitel_ii = [g for g in GRUPPEN if g['id'].startswith('II.')]
272 fragen_ii = sum(len(g['fragen']) for g in kapitel_ii)
273 gesamt = sum(len(g['fragen']) for g in GRUPPEN)
274
275 fehler = 0
276 for muster, erwartet, was in (
277 (r'(\d+) Gruppen für die \d+ Fragen des Kapitels II', len(kapitel_ii),
278 'Gruppen in Kapitel II'),
279 (r'\d+ Gruppen für die (\d+) Fragen des Kapitels II', fragen_ii,
280 'Fragen in Kapitel II'),
281 (r'sind es (\d+) Gruppen für', len(GRUPPEN), 'Gruppen insgesamt'),
282 (r'sind es \d+ Gruppen für (\d+) Fragen', gesamt, 'Fragen insgesamt'),
283 ):
284 treffer = re.search(muster, kopf)
285 if treffer is None:
286 print(f' KOMMENTAR: keine Angabe zu {was} über GRUPPEN gefunden')
287 fehler += 1
288 elif int(treffer.group(1)) != erwartet:
289 print(
290 f' KOMMENTAR: über GRUPPEN stehen {treffer.group(1)} {was}, '
291 f'gezählt sind es {erwartet}'
292 )
293 fehler += 1
294
295 if not fehler:
296 print(' ok: die Zahlen über GRUPPEN sind gezählt')
297 return fehler
298
299
300 def selbsttest(fragen: dict[str, dict]) -> int:
301 """Prüft die Prüfung: Jede Zusage wird einmal absichtlich verletzt."""
302 fehler = gliederungszahlen_pruefen()
303 beispiel = next(f for f in fragen.values() if f['kapitel'] == 'II')['id']
304 zweites = next(
305 f for f in fragen.values() if f['kapitel'] == 'II' and f['id'] != beispiel
306 )['id']
307 drittes = next(
308 f
309 for f in fragen.values()
310 if f['kapitel'] == 'II' and f['id'] not in (beispiel, zweites)
311 )['id']
312 faelle = [
313 ('Luecke', [{'id': 'X', 'titel': 'T', 'fragen': [beispiel, zweites, drittes]}],
314 'liegt in keiner Gruppe'),
315 ('erfundene Frage',
316 [{'id': 'X', 'titel': 'T', 'fragen': ['II-99', beispiel, zweites]}],
317 'gibt es im Katalog nicht'),
318 ('zu kleine Gruppe', [{'id': 'X', 'titel': 'T', 'fragen': [beispiel]}],
319 'mindestens'),
320 ('Titel fehlt',
321 [{'id': 'X', 'titel': ' ', 'fragen': [beispiel, zweites, drittes]}],
322 'Titel fehlt'),
323 ]
324 for name, gruppen, erwartet in faelle:
325 befunde = pruefen(gruppen, fragen)
326 if any(erwartet in b for b in befunde):
327 print(f' erkannt: {name}')
328 else:
329 print(f' DURCHGELASSEN: {name}')
330 fehler += 1
331
332 # Doppelte Zuordnung braucht zwei Gruppen.
333 doppelt = pruefen(
334 [
335 {'id': 'A', 'titel': 'T', 'fragen': [beispiel, zweites, drittes]},
336 {'id': 'B', 'titel': 'T', 'fragen': [beispiel, zweites, drittes]},
337 ],
338 fragen,
339 )
340 if any('steht in' in b for b in doppelt):
341 print(' erkannt: doppelte Zuordnung')
342 else:
343 print(' DURCHGELASSEN: doppelte Zuordnung')
344 fehler += 1
345
346 return fehler
347
348
349 def main() -> int:
350 katalog = json.loads(KATALOG.read_text(encoding='utf-8'))
351 fragen = {f['id']: f for f in katalog['fragen']}
352
353 if '--selbsttest' in sys.argv:
354 fehler = selbsttest(fragen)
355 print('Selbsttest bestanden.' if fehler == 0 else f'{fehler} Fehler.')
356 return 1 if fehler else 0
357
358 befunde = pruefen(GRUPPEN, fragen)
359 if befunde:
360 for befund in befunde:
361 print(f' {befund}')
362 print(f'ABBRUCH: {len(befunde)} Beanstandungen - es wird nichts geschrieben.')
363 return 1
364
365 gruppen = [
366 {
367 'id': gruppe['id'],
368 'kapitel': fragen[gruppe['fragen'][0]]['kapitel'],
369 'titel': gruppe['titel'],
370 'fragen': gruppe['fragen'],
371 }
372 for gruppe in GRUPPEN
373 ]
374
375 daten = {
376 'meta': {'version': 1, 'stand': date.today().isoformat(), 'hinweis': HINWEIS},
377 'gruppen': gruppen,
378 }
379 ZIEL.write_text(json.dumps(daten, ensure_ascii=False, indent=1) + '\n',
380 encoding='utf-8',
381 # LF wie im Archiv verlangt, nicht das CRLF von Windows.
382 newline='\n',
383 )
384
385 je_kapitel: dict[str, int] = {}
386 for gruppe in gruppen:
387 je_kapitel[gruppe['kapitel']] = je_kapitel.get(gruppe['kapitel'], 0) + len(
388 gruppe['fragen']
389 )
390 verteilung = ', '.join(f'{k}: {v}' for k, v in sorted(je_kapitel.items()))
391 print(f'{len(gruppen)} Gruppen, {sum(je_kapitel.values())} Fragen ({verteilung}).')
392 print(f'Geschrieben: {ZIEL.relative_to(WURZEL)}')
393 return 0
394
395
396 if __name__ == '__main__':
397 raise SystemExit(main())