waffensachkunde

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

/ app src shared erklaerungen.ts

11,0 KB Rohdatei
app/src/shared/erklaerungen.ts — 285 Zeilen
1 /**
2 * Erklärungen zu den Fragen des amtlichen Katalogs.
3 *
4 * Der Fragenkatalog des Bundesverwaltungsamtes nennt bei Freitextfragen eine
5 * Musterantwort und markiert bei Auswahlfragen die zutreffenden Antworten. Was
6 * er nicht sagt: **warum**. Genau das leisten die Erklärungen – und das ist
7 * der eine Punkt, an dem kein Wettbewerber etwas anbietet.
8 *
9 * ## Trennung vom amtlichen Werk
10 *
11 * Die Erklärungen sind eigener redaktioneller Inhalt und stehen deshalb in
12 * einer eigenen Datei, nicht im Katalog. Der amtliche Katalog ist ein
13 * amtliches Werk nach § 5 Abs. 2 UrhG und wird unverändert wiedergegeben
14 * (§ 62 UrhG); eigene Zusätze dürfen ihn nicht vermischen. In der Oberfläche
15 * sind sie sichtbar als Ergänzung gekennzeichnet.
16 *
17 * ## Zusage an die Richtigkeit
18 *
19 * Jede Fundstelle ist **strukturiert** angegeben, nicht als Fließtext. Nur so
20 * lässt sie sich maschinell gegen den amtlichen Gesetzestext prüfen:
21 * `data-pipeline/pruefe_erklaerungen.py` schlägt jede einzelne Angabe im Index
22 * aus `content/gesetze/index.json` nach und bricht ab, wenn ein Paragraf, ein
23 * Absatz, eine Nummer oder ein Buchstabe nicht existiert. Eine erfundene
24 * Fundstelle kommt damit nicht in die Auslieferung.
25 *
26 * Was die Prüfung **nicht** leisten kann: ob die zitierte Norm die Aussage
27 * auch trägt. Dafür gibt es die redaktionelle Durchsicht – und die
28 * ausdrückliche Angabe {@link Erklaerung.ohneFundstelleGrund}, die verlangt,
29 * das Fehlen einer Fundstelle zu begründen, statt eine zu erfinden.
30 */
31
32 /** Gesetze, auf die sich eine Fundstelle beziehen darf. */
33 export const GESETZE = [
34 'WaffG',
35 'AWaffV',
36 'BeschG',
37 'BeschussV',
38 'StGB',
39 'SprengG',
40 '1. SprengV',
41 ] as const;
42
43 export type Gesetzeskuerzel = (typeof GESETZE)[number];
44
45 /** Ausgeschriebene Bezeichnung für die Anzeige. */
46 export const GESETZ_BEZEICHNUNG: Readonly<Record<Gesetzeskuerzel, string>> = Object.freeze({
47 WaffG: 'Waffengesetz',
48 AWaffV: 'Allgemeine Waffengesetz-Verordnung',
49 BeschG: 'Beschussgesetz',
50 BeschussV: 'Beschussverordnung',
51 StGB: 'Strafgesetzbuch',
52 SprengG: 'Sprengstoffgesetz',
53 '1. SprengV': 'Erste Verordnung zum Sprengstoffgesetz',
54 });
55
56 /**
57 * Eine Fundstelle im Gesetz – strukturiert, damit sie prüfbar bleibt.
58 *
59 * Bewusst kein Fließtext: „siehe § 12 WaffG" ließe sich nicht nachschlagen,
60 * ohne den Satz zu zerlegen, und jede Abweichung in der Schreibweise wäre ein
61 * neues Sonderproblem.
62 */
63 export interface Fundstelle {
64 readonly gesetz: Gesetzeskuerzel;
65 /** „§ 12" oder „Anlage 1". */
66 readonly norm: string;
67 readonly absatz?: string;
68 readonly nummer?: string;
69 readonly buchstabe?: string;
70 readonly satz?: string;
71 /**
72 * Nur bei Anlagen: die Stelle innerhalb der Anlage, etwa
73 * „Abschnitt 1 Unterabschnitt 1 Nr. 1.1". Anlagen sind nicht in Absätze
74 * gegliedert; geprüft wird deshalb durch Textsuche.
75 */
76 readonly stelle?: string;
77 }
78
79 /** Erklärung zu genau einer Frage. */
80 export interface Erklaerung {
81 /**
82 * Ein Satz, der die richtige Antwort trägt. Steht unmittelbar unter der
83 * Rückmeldung und muss für sich allein verständlich sein.
84 */
85 readonly kurz: string;
86 /**
87 * Ausführliche Begründung: warum die richtige Antwort richtig ist und –
88 * wo es hilft – warum die anderen es nicht sind.
89 */
90 readonly text: string;
91 /** Belege im Gesetz. Darf leer sein, dann ist {@link ohneFundstelleGrund} nötig. */
92 readonly fundstellen: readonly Fundstelle[];
93 /**
94 * Warum es zu dieser Frage keine Fundstelle gibt.
95 *
96 * Nicht jede Prüfungsfrage hat eine gesetzliche Grundlage: Die
97 * Sicherheitsregeln zur Handhabung und weite Teile der Waffentechnik sind
98 * fachliche Praxis, nicht Gesetzestext. Dieses Feld verlangt, das
99 * auszusprechen – die Alternative wäre, eine Fundstelle zu erfinden.
100 */
101 readonly ohneFundstelleGrund?: string;
102 /** Optionale Eselsbrücke. Nur, wo sie wirklich trägt. */
103 readonly merksatz?: string;
104 /**
105 * Fragen, die denselben Gegenstand behandeln.
106 *
107 * **Wozu.** Der amtliche Katalog kennt keine Frage-zu-Frage-Beziehung, obwohl
108 * die Bündel auf der Hand liegen: II-34 fragt, was ein Schalldämpfer bewirkt,
109 * II-36 fragt nach der Umkehrung – die Erklärung zu II-36 sagt das selbst im
110 * Text. Wer eine Frage gerade verstanden hat, ist im besten Moment, die
111 * Nachbarfrage mitzunehmen; bis 0.22.0 erfuhr er nie, dass es sie gibt.
112 *
113 * **Redaktionell, nicht amtlich.** Die Zuordnung ist eine Einschätzung dieser
114 * Software. Sie ist bewusst **beidseitig**: Steht B bei A, muss A bei B
115 * stehen. Eine einseitige Verknüpfung wäre fast immer ein Versehen beim
116 * Schreiben, und sie zeigte die Verbindung nur dem, der zufällig von der
117 * richtigen Seite kommt. `pruefe_erklaerungen.py` besteht darauf.
118 *
119 * **Keine Wertung.** Die Liste sagt „das gehört zusammen“, nicht „das musst
120 * du auch können“ – der Prüfungsstoff steht im Katalog, nicht hier.
121 */
122 readonly verwandt?: readonly string[];
123 /**
124 * Woran sich eine vollständige Antwort erkennen lässt – bei offenen Fragen.
125 *
126 * **Wozu.** Bei 63 der 104 offenen Fragen ist in der amtlichen
127 * Musterantwort nichts unterstrichen, und die Anwendung gibt die
128 * Unterstreichungen seit 0.17.0 zu Recht nicht mehr als Pflichtinhalt aus
129 * (`docs/entscheidung-freitext.md`). Damit stand der Lernende bei der
130 * vierstufigen Selbstbewertung – die unmittelbar die Wiedervorlage steuert
131 * – ohne jedes Kriterium da: Die Hilfe warnt vor zu milder Bewertung, gab
132 * aber kein Werkzeug, das Wesentliche zu erkennen.
133 *
134 * **Was das Feld nicht ist.** Es sagt **nicht**, was eine Antwort enthalten
135 * muss. Über einer Liste wie dieser stand bis 0.17.0 „Diese Kernelemente
136 * muss Ihre Antwort enthalten“, und genau das war unbelegt. Die Punkte sind
137 * eine Einschätzung dieser Software, in eigenen Worten aus der amtlichen
138 * Musterantwort erarbeitet – eine Erkennungshilfe, kein
139 * Bewertungsversprechen. Wer prüft, ist weiterhin der Prüfungsausschuss;
140 * wer die eigene Antwort bewertet, bleibt der Lernende.
141 *
142 * **Warum nicht überall.** Wo die Musterantwort aus einer einzigen Tatsache
143 * besteht („Unbefristet“, „Nein“, „Mindestens alle drei Jahre“), fehlt das
144 * Feld. Eine Prüfliste mit einem Punkt wäre kein Werkzeug, sondern Beiwerk.
145 */
146 readonly kernpunkte?: readonly string[];
147 }
148
149 export interface ErklaerungenMeta {
150 readonly version: number;
151 /** Tag, an dem der Bestand zuletzt geprüft wurde (ISO-Datum). */
152 readonly stand: string;
153 /**
154 * Stand der Gesetze, gegen die geprüft wurde – wörtlich aus dem amtlichen
155 * XML. Ändert sich ein Gesetz, ist erkennbar, worauf sich die Erklärungen
156 * bezogen haben.
157 */
158 readonly gesetzesstand: Readonly<Record<string, string>>;
159 readonly hinweis: string;
160 }
161
162 export interface Erklaerungen {
163 readonly meta: ErklaerungenMeta;
164 /** Frage-ID aus dem Katalog -> Erklärung. Nicht jede Frage muss eine haben. */
165 readonly zuFrage: Readonly<Record<string, Erklaerung>>;
166 }
167
168 /**
169 * Sammelt die verwandten Fragen zu einer Reihe von Fragen.
170 *
171 * Fragen, die schon in der Eingabe stehen, fallen heraus: Wer sie eben
172 * bearbeitet hat, braucht sie nicht als „verwandt“ vorgeschlagen zu bekommen.
173 * Die Reihenfolge bleibt die, in der man ihnen begegnet ist – nach Kennung zu
174 * sortieren ginge schief, weil „I.2-99“ vor „I.2-135“ kommt, lexikografisch
175 * aber dahinter steht.
176 *
177 * Der Zugriff kommt als Funktion herein und nicht als ganzer Bestand: Die
178 * Oberfläche hält die Erklärungen ohnehin nur hinter einem Nachschlager, und
179 * so lässt sich die Sammlung ohne Attrappe des vollen Vertrags prüfen.
180 */
181 export function verwandteSammeln(
182 zu: (frageId: string) => Erklaerung | null,
183 frageIds: readonly string[],
184 ): string[] {
185 const schonDa = new Set(frageIds);
186 const gesammelt: string[] = [];
187 const gesehen = new Set<string>();
188
189 for (const frageId of frageIds) {
190 for (const verwandt of zu(frageId)?.verwandt ?? []) {
191 if (!schonDa.has(verwandt) && !gesehen.has(verwandt)) {
192 gesehen.add(verwandt);
193 gesammelt.push(verwandt);
194 }
195 }
196 }
197
198 return gesammelt;
199 }
200
201 /**
202 * Fundstelle als Zitat, wie es in der Oberfläche erscheint.
203 *
204 * Eine einzige Stelle, an der die Schreibweise festgelegt wird – sonst hätte
205 * jede Erklärung ihre eigene.
206 */
207 export function fundstelleText(fundstelle: Fundstelle): string {
208 const teile: string[] = [fundstelle.norm];
209
210 if (fundstelle.stelle !== undefined && fundstelle.stelle.length > 0) {
211 teile.push(fundstelle.stelle);
212 }
213 if (fundstelle.absatz !== undefined) {
214 teile.push(`Abs. ${fundstelle.absatz}`);
215 }
216 if (fundstelle.satz !== undefined) {
217 teile.push(`Satz ${fundstelle.satz}`);
218 }
219 if (fundstelle.nummer !== undefined) {
220 teile.push(`Nr. ${fundstelle.nummer}`);
221 }
222 if (fundstelle.buchstabe !== undefined) {
223 teile.push(`Buchst. ${fundstelle.buchstabe}`);
224 }
225
226 return `${teile.join(' ')} ${fundstelle.gesetz}`;
227 }
228
229 /** Vollständiger Name für Bildschirmleser: Abkürzungen ausgeschrieben. */
230 export function fundstelleVorlesetext(fundstelle: Fundstelle): string {
231 const teile: string[] = [fundstelle.norm.replace('§', 'Paragraf')];
232
233 if (fundstelle.stelle !== undefined && fundstelle.stelle.length > 0) {
234 teile.push(fundstelle.stelle);
235 }
236 if (fundstelle.absatz !== undefined) {
237 teile.push(`Absatz ${fundstelle.absatz}`);
238 }
239 if (fundstelle.satz !== undefined) {
240 teile.push(`Satz ${fundstelle.satz}`);
241 }
242 if (fundstelle.nummer !== undefined) {
243 teile.push(`Nummer ${fundstelle.nummer}`);
244 }
245 if (fundstelle.buchstabe !== undefined) {
246 teile.push(`Buchstabe ${fundstelle.buchstabe}`);
247 }
248
249 return `${teile.join(' ')} ${GESETZ_BEZEICHNUNG[fundstelle.gesetz]}`;
250 }
251
252 /**
253 * Die ganze Erklärung als vorlesbarer Text.
254 *
255 * **Wozu.** Die ausführliche Begründung ist der längste Text der Anwendung –
256 * gesprochen über eine Minute. Bis 0.22.0 gab es keinen Weg, sie auf Abruf zu
257 * hören: Die Vorlesetaste der Sitzung liest sie bewusst nicht mit (die
258 * nächste Frage wartet, siehe `shared/ipc.ts`), und die Automatik greift nur
259 * bei falscher Antwort. Wer bei einer richtigen Antwort trotzdem wissen
260 * wollte, warum – oder wer sie in der Prüfungsauswertung nachlas –, war aufs
261 * stille Lesen zurückgeworfen. Ausgerechnet in der Nachbereitung, dem
262 * lernwirksamsten Moment.
263 *
264 * Die Fundstellen kommen in der ausgeschriebenen Form mit: „§ 12 Abs. 1“
265 * würde sonst buchstabiert oder falsch betont. Der Herkunftssatz bleibt
266 * draußen – er steht am Bildschirm und ist keine Auskunft zur Sache.
267 */
268 export function erklaerungVorlesetext(erklaerung: Erklaerung): string {
269 const teile: string[] = [erklaerung.kurz, erklaerung.text];
270
271 if (erklaerung.merksatz !== undefined) {
272 teile.push(`Merksatz: ${erklaerung.merksatz}`);
273 }
274
275 if (erklaerung.fundstellen.length > 0) {
276 const liste = erklaerung.fundstellen.map((fundstelle) => fundstelleVorlesetext(fundstelle));
277 teile.push(`Im Gesetz nachlesen: ${liste.join('; ')}.`);
278 }
279
280 if (erklaerung.ohneFundstelleGrund !== undefined) {
281 teile.push(erklaerung.ohneFundstelleGrund);
282 }
283
284 return teile.join('\n');
285 }