waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
/ app src shared erklaerungen.ts
| 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 | } |