waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
| 1 | /** |
| 2 | * Datentypen des amtlichen Fragenkatalogs. |
| 3 | * |
| 4 | * Spiegelt exakt das Format von `content/katalog/katalog.json`, das die |
| 5 | * Datenpipeline (`data-pipeline/parse_catalog.py`) aus dem BVA-PDF erzeugt. |
| 6 | * Der Fragenwortlaut ist amtlicher Inhalt und wird unverändert dargestellt; |
| 7 | * eigene didaktische Ergänzungen liegen getrennt davon. |
| 8 | */ |
| 9 | |
| 10 | /** |
| 11 | * Ein Textabschnitt mit optionaler Hervorhebung. |
| 12 | * |
| 13 | * Im Katalog sind die Kernelemente der Musterantworten unterstrichen – also |
| 14 | * jene Bestandteile, die eine Antwort in der Prüfung enthalten muss. Sie |
| 15 | * werden hier als `h: true` geführt und tragen später das Freitext-Training. |
| 16 | */ |
| 17 | export interface TextSegment { |
| 18 | /** Textinhalt des Abschnitts. */ |
| 19 | readonly t: string; |
| 20 | /** |
| 21 | * `true`, wenn die Stelle im amtlichen Katalog unterstrichen ist. |
| 22 | * |
| 23 | * Was das Amt damit meint, sagt es nirgends. In Musterantworten liest die |
| 24 | * Anwendung es als Hervorhebung, in Fragetexten als Betonung (meist einer |
| 25 | * Verneinung) – dieselbe Auszeichnung in zwei Rollen. Was sie NICHT ist: |
| 26 | * eine Vorgabe, was eine richtige Antwort enthalten muss. |
| 27 | */ |
| 28 | readonly h?: boolean; |
| 29 | } |
| 30 | |
| 31 | /** Text mit Auszeichnung; `text` ist dieselbe Zeichenfolge ohne Auszeichnung. */ |
| 32 | export interface RichText { |
| 33 | /** Reiner Text – für Suche, Vorlesefunktion und Textvergleich. */ |
| 34 | readonly text: string; |
| 35 | /** Derselbe Text, zerlegt in ausgezeichnete Abschnitte. */ |
| 36 | readonly segmente: readonly TextSegment[]; |
| 37 | } |
| 38 | |
| 39 | /** Abbildung im Katalog – ausschließlich amtliche Prüf- und Zulassungszeichen. */ |
| 40 | export interface KatalogBild { |
| 41 | readonly id: string; |
| 42 | readonly datei: string; |
| 43 | readonly breite: number; |
| 44 | readonly hoehe: number; |
| 45 | /** |
| 46 | * Alternativtext. Redaktioneller Inhalt aus `content/alttexte.json`. |
| 47 | * Bei Fragen, in denen die Zeichen selbst die Antwortoptionen sind, |
| 48 | * beschreibt er nur die Form und verrät die Lösung nicht. |
| 49 | */ |
| 50 | readonly alt: string | null; |
| 51 | /** |
| 52 | * Erklärende Bedeutung des Zeichens – zweite Stufe des Bildkonzepts aus |
| 53 | * dem Prüfplan (Szenario S4). Ebenfalls redaktioneller Inhalt aus |
| 54 | * `content/alttexte.json`. Weil sie die Lösung verraten kann, zeigt die |
| 55 | * Oberfläche sie erst NACH dem Beantworten und nie im Prüfungslauf. |
| 56 | */ |
| 57 | readonly beschreibung: string | null; |
| 58 | } |
| 59 | |
| 60 | /** Eine Antwortmöglichkeit einer Multiple-Choice-Frage. */ |
| 61 | export interface Antwortoption { |
| 62 | /** Amtliches Label, „a“ bis „h“. */ |
| 63 | readonly label: string; |
| 64 | readonly inhalt: RichText; |
| 65 | readonly korrekt: boolean; |
| 66 | /** Bild-IDs; bei einigen Fragen ist das Zeichen selbst die Antwort. */ |
| 67 | readonly bilder: readonly string[]; |
| 68 | } |
| 69 | |
| 70 | /** |
| 71 | * Fragetyp. |
| 72 | * - `mc`: Multiple Choice. Achtung: 114 der 471 MC-Fragen haben mehr als eine |
| 73 | * richtige Antwort, die Auswahl muss also mehrfach zulassen. |
| 74 | * - `freitext`: offene Frage, wird gegen eine Musterantwort selbst bewertet. |
| 75 | * - `lueckentext`: Sonderfall (nur Frage 5.01). |
| 76 | */ |
| 77 | export type Fragetyp = 'mc' | 'freitext' | 'lueckentext'; |
| 78 | |
| 79 | export interface Frage { |
| 80 | /** Stabile ID, z. B. „I.1-01“ oder „IV-89“. */ |
| 81 | readonly id: string; |
| 82 | /** Nummer wie im Katalog, z. B. „1.01“ – bleibt für Nutzer sichtbar. */ |
| 83 | readonly amtliche_nummer: string; |
| 84 | readonly kapitel: string; |
| 85 | readonly abschnitt: string | null; |
| 86 | readonly typ: Fragetyp; |
| 87 | /** Seite im Original-PDF – erlaubt das Nachschlagen in der Vorlage. */ |
| 88 | readonly seite: number; |
| 89 | readonly frage: RichText; |
| 90 | readonly bilder: readonly string[]; |
| 91 | /** Nur bei `typ === 'mc'`. */ |
| 92 | readonly optionen?: readonly Antwortoption[]; |
| 93 | /** Nur bei offenen Fragen und beim Lückentext. */ |
| 94 | readonly musterantwort?: RichText; |
| 95 | /** Hinweise aus der Extraktion, die eine Sichtprüfung verdienen. */ |
| 96 | readonly warnungen?: readonly string[]; |
| 97 | } |
| 98 | |
| 99 | export interface Abschnitt { |
| 100 | readonly id: string; |
| 101 | readonly titel: string; |
| 102 | } |
| 103 | |
| 104 | export interface Kapitel { |
| 105 | readonly id: string; |
| 106 | readonly titel: string; |
| 107 | readonly abschnitte: readonly Abschnitt[]; |
| 108 | } |
| 109 | |
| 110 | export interface KatalogMeta { |
| 111 | readonly titel: string; |
| 112 | readonly herausgeber: string; |
| 113 | /** Katalogstand als ISO-Datum, z. B. „2024-12-16“. */ |
| 114 | readonly stand: string; |
| 115 | /** Pflichtangabe, die in der Oberfläche sichtbar geführt wird. */ |
| 116 | readonly quellenangabe: string; |
| 117 | readonly quelle_url: string; |
| 118 | readonly quelldatei_sha256: string; |
| 119 | readonly fragen_gesamt: number; |
| 120 | } |
| 121 | |
| 122 | export interface Katalog { |
| 123 | readonly meta: KatalogMeta; |
| 124 | readonly kapitel: readonly Kapitel[]; |
| 125 | readonly bilder: readonly KatalogBild[]; |
| 126 | readonly fragen: readonly Frage[]; |
| 127 | } |
| 128 | |
| 129 | // ─── Hilfsfunktionen ──────────────────────────────────────────────────────── |
| 130 | |
| 131 | /** Ist bei dieser Frage mehr als eine Antwort richtig? */ |
| 132 | export function istMehrfachauswahl(frage: Frage): boolean { |
| 133 | return anzahlRichtiger(frage) > 1; |
| 134 | } |
| 135 | |
| 136 | export function anzahlRichtiger(frage: Frage): number { |
| 137 | return frage.optionen?.filter((o) => o.korrekt).length ?? 0; |
| 138 | } |
| 139 | |
| 140 | /** Labels aller richtigen Optionen, aufsteigend sortiert. */ |
| 141 | export function richtigeLabels(frage: Frage): string[] { |
| 142 | return (frage.optionen ?? []).filter((o) => o.korrekt).map((o) => o.label); |
| 143 | } |
| 144 | |
| 145 | /** |
| 146 | * Bewertet eine Multiple-Choice-Auswahl. |
| 147 | * |
| 148 | * Eine Antwort gilt nur als richtig, wenn genau die richtigen Optionen gewählt |
| 149 | * wurden – nicht weniger und nicht mehr. Das entspricht der Prüfungspraxis: |
| 150 | * eine unvollständige Auswahl ist keine richtige Antwort. |
| 151 | */ |
| 152 | export function bewerteAuswahl( |
| 153 | frage: Frage, |
| 154 | auswahl: readonly string[], |
| 155 | ): { richtig: boolean; fehlend: string[]; zuviel: string[] } { |
| 156 | const soll = new Set(richtigeLabels(frage)); |
| 157 | const ist = new Set(auswahl); |
| 158 | const fehlend = [...soll].filter((l) => !ist.has(l)).sort(); |
| 159 | const zuviel = [...ist].filter((l) => !soll.has(l)).sort(); |
| 160 | return { richtig: fehlend.length === 0 && zuviel.length === 0, fehlend, zuviel }; |
| 161 | } |
| 162 | |
| 163 | /** |
| 164 | * Die im amtlichen Katalog unterstrichenen Stellen einer Musterantwort. |
| 165 | * |
| 166 | * Hieß bis Fassung 0.17.0 „Kernelemente – die Bestandteile, die enthalten |
| 167 | * sein müssen“. Der Name blieb, die Behauptung ist weg: Nachgemessen |
| 168 | * schwankt der unterstrichene Anteil zwischen 3 und 100 Prozent der |
| 169 | * Musterantwort, und bei 63 der 104 offenen Fragen ist gar nichts |
| 170 | * unterstrichen. Als Pflichtinhalt taugt das nicht, als Lesehilfe schon. |
| 171 | */ |
| 172 | export function kernelemente(text: RichText | undefined): string[] { |
| 173 | if (!text) return []; |
| 174 | return text.segmente |
| 175 | .filter((s) => s.h) |
| 176 | .map((s) => s.t.trim()) |
| 177 | .filter((s) => s.length > 0); |
| 178 | } |
| 179 | |
| 180 | /** Menschlich lesbare Bezeichnung eines Fragetyps. */ |
| 181 | export function fragetypBezeichnung(typ: Fragetyp): string { |
| 182 | switch (typ) { |
| 183 | case 'mc': |
| 184 | return 'Multiple Choice'; |
| 185 | case 'freitext': |
| 186 | return 'Frage zum Ausformulieren'; |
| 187 | case 'lueckentext': |
| 188 | return 'Lückentext'; |
| 189 | } |
| 190 | } |