waffensachkunde

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

/ data-pipeline themen_bauen.py

12,3 KB Rohdatei
data-pipeline/themen_bauen.py — 346 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 json
36 import sys
37 from datetime import date
38 from pathlib import Path
39
40 WURZEL = Path(__file__).resolve().parent.parent
41 KATALOG = WURZEL / 'content' / 'katalog' / 'katalog.json'
42 ZIEL = WURZEL / 'content' / 'themen.json'
43
44 #: Kapitel ohne amtliche Abschnittsgliederung. Kapitel I bleibt außen vor –
45 #: dort gibt es die Abschnitte des Bundesverwaltungsamtes, und eine zweite
46 #: Gliederung daneben wäre eine Zahl, die auseinanderläuft.
47 KAPITEL = ('II', 'III', 'IV')
48
49 #: Weniger als so viele Fragen sind kein Thema, sondern ein Einzelfall.
50 MINDESTGROESSE = 3
51
52 HINWEIS = (
53 'Redaktionelle Feingliederung dieser Software, nicht Teil des amtlichen '
54 'Fragenkatalogs des Bundesverwaltungsamtes. Der amtliche Katalog gliedert nur '
55 'Kapitel I in Abschnitte; fuer die 230 Fragen der Kapitel II bis IV gibt es dort '
56 'keine Untergliederung. Nicht von Hand aendern - neu erzeugen mit: '
57 'python data-pipeline/themen_bauen.py'
58 )
59
60
61 def n(kapitel: str, *nummern: int) -> list[str]:
62 """Fragen-IDs aus Nummern – die Kapitel II bis IV zählen zweistellig."""
63 return [f'{kapitel}-{nummer:02d}' for nummer in nummern]
64
65
66 #: Die Gliederung. Von Hand geschrieben und bewusst grob: Zwölf Gruppen für
67 #: 92 Fragen sind lernbare Portionen; dreißig wären ein zweites Inhaltsverzeichnis.
68 GRUPPEN: list[dict] = [
69 # ── Kapitel II: Waffentechnik ─────────────────────────────────────────
70 {
71 'id': 'II.1',
72 'titel': 'Waffenarten und Funktionsprinzipien',
73 'fragen': n('II', 1, 2, 3, 4, 6, 7, 8, 9, 11, 12, 16, 18, 19, 45, 46, 47, 56, 58),
74 },
75 {
76 'id': 'II.2',
77 'titel': 'Wesentliche Teile und Baugruppen',
78 'fragen': n('II', 5, 13, 14, 15, 55, 88, 89),
79 },
80 {
81 'id': 'II.3',
82 'titel': 'Lauf, Züge und Felder, Zubehör',
83 'fragen': n('II', 10, 37, 38, 39, 49, 50, 51, 57),
84 },
85 {
86 'id': 'II.4',
87 'titel': 'Schalldämpfer und Schallentstehung',
88 'fragen': n('II', 33, 34, 35, 36, 86, 87),
89 },
90 {'id': 'II.5', 'titel': 'Sicherungen', 'fragen': n('II', 52, 53, 54)},
91 {
92 'id': 'II.6',
93 'titel': 'Munition: Aufbau und Zündung',
94 'fragen': n('II', 20, 21, 22, 23, 25, 26, 48),
95 },
96 {
97 'id': 'II.7',
98 'titel': 'Kaliber und Munitionsbezeichnung',
99 'fragen': n('II', 17, 24, 27, 28, 29, 30, 31, 32, 40, 41, 42, 43, 44),
100 },
101 {
102 'id': 'II.8',
103 'titel': 'Ballistik: Begriffe und Teilgebiete',
104 'fragen': n('II', 60, 61, 62, 74, 75, 76, 79, 80, 85),
105 },
106 {
107 'id': 'II.9',
108 'titel': 'Flugbahn, Drall und Streuung',
109 'fragen': n('II', 59, 77, 78, 81, 82, 83, 84),
110 },
111 {
112 'id': 'II.10',
113 'titel': 'Reichweite und Gefährdungsbereich',
114 'fragen': n('II', 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73),
115 },
116 {'id': 'II.11', 'titel': 'Unbrauchbarmachung', 'fragen': n('II', 90, 91, 92)},
117 # ── Kapitel III: Handhabung von Schusswaffen ──────────────────────────
118 {
119 'id': 'III.1',
120 'titel': 'Grundregeln im Umgang mit Schusswaffen',
121 'fragen': n('III', 1, 8, 46),
122 },
123 {
124 'id': 'III.2',
125 'titel': 'Übergeben und Annehmen',
126 'fragen': n('III', 2, 9, 10, 13, 20),
127 },
128 {
129 'id': 'III.3',
130 'titel': 'Laden, Entladen und Schussbereitschaft',
131 'fragen': n('III', 11, 12, 27, 37, 38, 39, 40, 43, 45),
132 },
133 {
134 'id': 'III.4',
135 'titel': 'Ablegen und Tragen auf dem Stand',
136 'fragen': n('III', 18, 19, 21, 26, 33, 42, 44),
137 },
138 {
139 'id': 'III.5',
140 'titel': 'Schießstätte: Ordnung, Aufsicht und Gefahrenbereich',
141 'fragen': n('III', 15, 16, 17, 22, 23, 24, 25, 30, 31, 32, 34, 35, 48, 49),
142 },
143 {
144 'id': 'III.6',
145 'titel': 'Schießen außerhalb von Schießstätten',
146 'fragen': n('III', 3, 14, 28, 29),
147 },
148 {
149 'id': 'III.7',
150 'titel': 'Munition auf der Schießstätte',
151 'fragen': n('III', 4, 5, 6, 7, 36, 41, 47),
152 },
153 # ── Kapitel IV: Not- und Seenotsignalmittel ───────────────────────────
154 {
155 'id': 'IV.1',
156 'titel': 'Begriffe und Arten der Signalmittel',
157 'fragen': n('IV', 1, 2, 3, 9, 41, 42),
158 },
159 {
160 'id': 'IV.2',
161 'titel': 'Verwendung im Notfall',
162 'fragen': n('IV', 4, 6, 7, 8, 10, 11, 12, 13, 43, 44, 45, 53, 59, 83),
163 },
164 {
165 'id': 'IV.3',
166 'titel': 'Technische Daten und Aufbau',
167 'fragen': n('IV', 5, 48, 51, 52, 55, 57, 58),
168 },
169 {
170 'id': 'IV.4',
171 'titel': 'Kennzeichnung, Zulassung und Klassen',
172 'fragen': n('IV', 39, 40, 47, 49, 50, 54, 56, 89),
173 },
174 {
175 'id': 'IV.5',
176 'titel': 'Haltbarkeit, Lagerung und Entsorgung',
177 'fragen': n('IV', 14, 15, 16, 17, 18, 19, 25, 26, 46),
178 },
179 {
180 'id': 'IV.6',
181 'titel': 'Aufbewahrung und Transport',
182 'fragen': n('IV', 20, 21, 22, 23, 24, 75, 76, 77),
183 },
184 {
185 'id': 'IV.7',
186 'titel': 'Erlaubnisse, Bedürfnis und Dokumente',
187 'fragen': n('IV', 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 80, 85, 86, 87, 88),
188 },
189 {'id': 'IV.8', 'titel': 'Überlassen und Führen', 'fragen': n('IV', 78, 79, 82)},
190 {
191 'id': 'IV.9',
192 'titel': 'Rechtsgrundlagen und Zuständigkeiten',
193 'fragen': n('IV', 61, 63, 64, 65, 66, 67, 68, 69, 70, 71),
194 },
195 {'id': 'IV.10', 'titel': 'Störungen und Versager', 'fragen': n('IV', 72, 73, 74)},
196 {
197 'id': 'IV.11',
198 'titel': 'Missbrauch und Meldepflichten',
199 'fragen': n('IV', 60, 62, 81, 84),
200 },
201 ]
202
203
204 def pruefen(gruppen: list[dict], fragen: dict[str, dict]) -> list[str]:
205 """Alle Zusagen dieser Datei. Leere Liste heißt: in Ordnung."""
206 befunde: list[str] = []
207
208 kennungen = [gruppe['id'] for gruppe in gruppen]
209 if len(set(kennungen)) != len(kennungen):
210 befunde.append('Doppelte Gruppenkennung')
211
212 zugeordnet: dict[str, str] = {}
213 for gruppe in gruppen:
214 if not gruppe['titel'].strip():
215 befunde.append(f'{gruppe["id"]}: Titel fehlt')
216 if len(gruppe['fragen']) < MINDESTGROESSE:
217 befunde.append(
218 f'{gruppe["id"]}: nur {len(gruppe["fragen"])} Fragen '
219 f'(mindestens {MINDESTGROESSE})'
220 )
221
222 kapitel_der_gruppe = set()
223 for frage_id in gruppe['fragen']:
224 frage = fragen.get(frage_id)
225 if frage is None:
226 befunde.append(f'{gruppe["id"]}: {frage_id} gibt es im Katalog nicht')
227 continue
228 kapitel_der_gruppe.add(frage['kapitel'])
229 if frage_id in zugeordnet:
230 befunde.append(
231 f'{frage_id} steht in {zugeordnet[frage_id]} und in {gruppe["id"]}'
232 )
233 zugeordnet[frage_id] = gruppe['id']
234
235 if len(kapitel_der_gruppe) > 1:
236 befunde.append(f'{gruppe["id"]}: mischt Kapitel {sorted(kapitel_der_gruppe)}')
237
238 # Die wichtigste Zusage: Vollständigkeit. Eine luecken­hafte Gliederung
239 # hiesse, dass „Thema ueben" Fragen stillschweigend uebergeht.
240 for frage_id, frage in fragen.items():
241 if frage['kapitel'] in KAPITEL and frage_id not in zugeordnet:
242 befunde.append(f'{frage_id} ({frage["kapitel"]}) liegt in keiner Gruppe')
243 if frage['kapitel'] not in KAPITEL and frage_id in zugeordnet:
244 befunde.append(f'{frage_id} gehoert zu Kapitel I und braucht keine Gruppe')
245
246 return befunde
247
248
249 def selbsttest(fragen: dict[str, dict]) -> int:
250 """Prüft die Prüfung: Jede Zusage wird einmal absichtlich verletzt."""
251 fehler = 0
252 beispiel = next(f for f in fragen.values() if f['kapitel'] == 'II')['id']
253 zweites = next(
254 f for f in fragen.values() if f['kapitel'] == 'II' and f['id'] != beispiel
255 )['id']
256 drittes = next(
257 f
258 for f in fragen.values()
259 if f['kapitel'] == 'II' and f['id'] not in (beispiel, zweites)
260 )['id']
261 faelle = [
262 ('Luecke', [{'id': 'X', 'titel': 'T', 'fragen': [beispiel, zweites, drittes]}],
263 'liegt in keiner Gruppe'),
264 ('erfundene Frage',
265 [{'id': 'X', 'titel': 'T', 'fragen': ['II-99', beispiel, zweites]}],
266 'gibt es im Katalog nicht'),
267 ('zu kleine Gruppe', [{'id': 'X', 'titel': 'T', 'fragen': [beispiel]}],
268 'mindestens'),
269 ('Titel fehlt',
270 [{'id': 'X', 'titel': ' ', 'fragen': [beispiel, zweites, drittes]}],
271 'Titel fehlt'),
272 ]
273 for name, gruppen, erwartet in faelle:
274 befunde = pruefen(gruppen, fragen)
275 if any(erwartet in b for b in befunde):
276 print(f' erkannt: {name}')
277 else:
278 print(f' DURCHGELASSEN: {name}')
279 fehler += 1
280
281 # Doppelte Zuordnung braucht zwei Gruppen.
282 doppelt = pruefen(
283 [
284 {'id': 'A', 'titel': 'T', 'fragen': [beispiel, zweites, drittes]},
285 {'id': 'B', 'titel': 'T', 'fragen': [beispiel, zweites, drittes]},
286 ],
287 fragen,
288 )
289 if any('steht in' in b for b in doppelt):
290 print(' erkannt: doppelte Zuordnung')
291 else:
292 print(' DURCHGELASSEN: doppelte Zuordnung')
293 fehler += 1
294
295 return fehler
296
297
298 def main() -> int:
299 katalog = json.loads(KATALOG.read_text(encoding='utf-8'))
300 fragen = {f['id']: f for f in katalog['fragen']}
301
302 if '--selbsttest' in sys.argv:
303 fehler = selbsttest(fragen)
304 print('Selbsttest bestanden.' if fehler == 0 else f'{fehler} Fehler.')
305 return 1 if fehler else 0
306
307 befunde = pruefen(GRUPPEN, fragen)
308 if befunde:
309 for befund in befunde:
310 print(f' {befund}')
311 print(f'ABBRUCH: {len(befunde)} Beanstandungen - es wird nichts geschrieben.')
312 return 1
313
314 gruppen = [
315 {
316 'id': gruppe['id'],
317 'kapitel': fragen[gruppe['fragen'][0]]['kapitel'],
318 'titel': gruppe['titel'],
319 'fragen': gruppe['fragen'],
320 }
321 for gruppe in GRUPPEN
322 ]
323
324 daten = {
325 'meta': {'version': 1, 'stand': date.today().isoformat(), 'hinweis': HINWEIS},
326 'gruppen': gruppen,
327 }
328 ZIEL.write_text(json.dumps(daten, ensure_ascii=False, indent=1) + '\n',
329 encoding='utf-8',
330 # LF wie im Archiv verlangt, nicht das CRLF von Windows.
331 newline='\n',
332 )
333
334 je_kapitel: dict[str, int] = {}
335 for gruppe in gruppen:
336 je_kapitel[gruppe['kapitel']] = je_kapitel.get(gruppe['kapitel'], 0) + len(
337 gruppe['fragen']
338 )
339 verteilung = ', '.join(f'{k}: {v}' for k, v in sorted(je_kapitel.items()))
340 print(f'{len(gruppen)} Gruppen, {sum(je_kapitel.values())} Fragen ({verteilung}).')
341 print(f'Geschrieben: {ZIEL.relative_to(WURZEL)}')
342 return 0
343
344
345 if __name__ == '__main__':
346 raise SystemExit(main())