waffensachkunde

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

/ app src shared katalog.ts

6,7 KB Rohdatei
app/src/shared/katalog.ts — 190 Zeilen
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 }