waffensachkunde

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

/ app src shared glossar.ts

7,2 KB Rohdatei
app/src/shared/glossar.ts — 201 Zeilen
1 /**
2 * Glossar der Fachbegriffe des Waffenrechts.
3 *
4 * Das Waffenrecht arbeitet mit Wörtern, die im Alltag etwas anderes bedeuten.
5 * „Führen" heißt nicht lenken, sondern die tatsächliche Gewalt außerhalb der
6 * eigenen Wohnung ausüben. „Erwerben" hat mit Kaufen nichts zu tun. Wer das
7 * nicht weiß, versteht die Frage nicht – unabhängig davon, wie gut er den
8 * Stoff kann.
9 *
10 * Genau darauf zielt WCAG 3.1.3 (Ungewöhnliche Wörter, Stufe AAA): Es muss
11 * einen Weg geben, die Bedeutung nachzuschlagen. 3.1.4 verlangt dasselbe für
12 * Abkürzungen; beide sind im Prüfplan dieses Projekts als Zusage aufgeführt.
13 *
14 * ## Herkunft der Definitionen
15 *
16 * Wo das Gesetz einen Begriff selbst bestimmt, wird diese Bestimmung
17 * wiedergegeben – nicht eine eigene Umschreibung davon. Die
18 * waffenrechtlichen Begriffe stehen in Anlage 1 Abschnitt 2 WaffG, die
19 * technischen in Abschnitt 1. Gesetzestexte sind nach § 5 Abs. 1 UrhG
20 * gemeinfrei.
21 *
22 * Jede Fundstelle wird wie bei den Erklärungen maschinell gegen den amtlichen
23 * Gesetzestext geprüft (`data-pipeline/pruefe_glossar.py`).
24 */
25
26 import type { Fundstelle } from './erklaerungen';
27
28 /**
29 * Art des Eintrags.
30 *
31 * Abkürzungen werden getrennt geführt: Sie brauchen keine Erläuterung,
32 * sondern die aufgelöste Form – und WCAG 3.1.4 verlangt genau das.
33 */
34 export type Begriffsart = 'begriff' | 'abkuerzung';
35
36 export interface Glossareintrag {
37 /** Anzeigeform, etwa „Führen" oder „WaffG". */
38 readonly begriff: string;
39 readonly art: Begriffsart;
40 /**
41 * Die Bedeutung in einem Satz. Bei Abkürzungen die aufgelöste Form.
42 *
43 * Steht für sich allein: Wer nur diesen Satz liest, muss die Frage
44 * verstehen können.
45 */
46 readonly kurz: string;
47 /** Ausführlichere Erläuterung, wo ein Satz nicht reicht. */
48 readonly text?: string;
49 /**
50 * Wortformen, unter denen der Begriff im Katalog vorkommt.
51 *
52 * Nötig, weil Deutsch flektiert: „Führen" erscheint als „führt",
53 * „geführt", „des Führens". Ohne die Formen bliebe der Begriff in genau
54 * den Fragen unerkannt, in denen er gebraucht wird.
55 */
56 readonly varianten: readonly string[];
57 readonly fundstellen: readonly Fundstelle[];
58 /** Warum es zu diesem Begriff keine gesetzliche Bestimmung gibt. */
59 readonly ohneFundstelleGrund?: string;
60 /** Verwandte Begriffe, die beim Verständnis helfen. */
61 readonly siehe?: readonly string[];
62 }
63
64 export interface GlossarMeta {
65 readonly version: number;
66 readonly stand: string;
67 readonly gesetzesstand: Readonly<Record<string, string>>;
68 readonly hinweis: string;
69 }
70
71 export interface Glossar {
72 readonly meta: GlossarMeta;
73 readonly eintraege: readonly Glossareintrag[];
74 }
75
76 /**
77 * Zeichen, die in einem deutschen Wort vorkommen.
78 *
79 * `\b` aus der regulären Ausdruckssprache taugt hier nicht: Es kennt nur
80 * ASCII-Wortzeichen. In „Schießstätte" gälte damit jedes ß und ä als
81 * Wortgrenze, und „Stätte" wäre plötzlich ein eigenes Wort.
82 */
83 const WORTZEICHEN = 'A-Za-zÄÖÜäöüßẞ0-9';
84
85 /** Escaped, was in einem regulären Ausdruck Bedeutung hätte. */
86 function maskiert(wert: string): string {
87 return wert.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
88 }
89
90 function ausdruckFuer(eintrag: Glossareintrag): RegExp {
91 // Längere Formen zuerst, damit „Kurzwaffe" nicht von „Waffe" verdeckt wird,
92 // falls beide als Variante desselben Eintrags stehen.
93 const alternativen = [...eintrag.varianten]
94 .sort((a, b) => b.length - a.length)
95 .map(maskiert)
96 .join('|');
97
98 return new RegExp(`(?<![${WORTZEICHEN}])(?:${alternativen})(?![${WORTZEICHEN}])`, 'giu');
99 }
100
101 /**
102 * Löst auf, wenn zwei Einträge dieselbe Fundstelle beanspruchen.
103 *
104 * ## Der Fall, um den es geht
105 *
106 * Deutsch schreibt Substantive groß, und daran hängt hier eine Bedeutung:
107 * „Geschossen" ist der Dativ Plural von „Geschoss", „geschossen" das Partizip
108 * von „schießen". Beide Wortformen stehen als Variante in **verschiedenen**
109 * Einträgen, und die Suche arbeitet ohne Rücksicht auf Groß- und
110 * Kleinschreibung — sie muss das, weil ein klein notiertes Verb am Satzanfang
111 * groß steht und ein nominalisiertes Verb („das Führen") ohnehin.
112 *
113 * Bis Fassung 0.24.1 bekam der Lernende deshalb beide Einträge angeboten. Am
114 * Katalog nachgezählt: zwölf Fragen mit dem Verb („Darf unter
115 * Alkoholeinfluss geschossen werden?") zeigten den Substantiveintrag
116 * „Geschoss", sieben mit dem Substantiv („Gefährdungsbereich von Geschossen")
117 * den Verbeintrag „Schießen".
118 *
119 * ## Die Regel
120 *
121 * Beanspruchen mehrere Einträge dieselbe Schreibweise im Text, gewinnt der,
122 * der sie **zeichengenau** als Variante führt. Führt keiner sie genau, bleibt
123 * es bei allen — dann ist die Groß-/Kleinschreibung kein Unterscheidungs-
124 * merkmal, und weniger zu zeigen wäre geraten.
125 *
126 * Ein Eintrag bleibt, sobald er **eine** seiner Fundstellen behält. „Das
127 * Führen ist erlaubnispflichtig" trifft weiterhin, weil dort niemand sonst
128 * Anspruch erhebt.
129 */
130 function eindeutigMachen(
131 treffer: readonly { readonly eintrag: Glossareintrag; readonly formen: readonly string[] }[],
132 ): Glossareintrag[] {
133 const genau = new Map<string, Set<string>>();
134 for (const { eintrag, formen } of treffer) {
135 for (const form of formen) {
136 if (eintrag.varianten.includes(form)) {
137 const bisher = genau.get(form) ?? new Set<string>();
138 bisher.add(eintrag.begriff);
139 genau.set(form, bisher);
140 }
141 }
142 }
143
144 return treffer
145 .filter(({ eintrag, formen }) =>
146 formen.some((form) => {
147 const beansprucht = genau.get(form);
148 return beansprucht === undefined || beansprucht.has(eintrag.begriff);
149 }),
150 )
151 .map(({ eintrag }) => eintrag);
152 }
153
154 /**
155 * Vorbereitetes Glossar – die Ausdrücke werden einmal gebaut, nicht je Frage.
156 */
157 export interface Begriffssuche {
158 readonly eintraege: readonly Glossareintrag[];
159 /** Findet die Begriffe, die in einem Text tatsächlich vorkommen. */
160 readonly imText: (text: string) => Glossareintrag[];
161 }
162
163 /**
164 * Bereitet die Suche vor.
165 *
166 * Einträge ohne Varianten werden übergangen: Ein Ausdruck aus einer leeren
167 * Alternativenliste würde auf jede Stelle passen und das ganze Glossar an
168 * jede Frage hängen.
169 */
170 export function begriffssucheBauen(glossar: Glossar): Begriffssuche {
171 const vorbereitet = glossar.eintraege
172 .filter((eintrag) => eintrag.varianten.length > 0)
173 .map((eintrag) => ({ eintrag, ausdruck: ausdruckFuer(eintrag) }));
174
175 return {
176 eintraege: glossar.eintraege,
177 imText: (text: string): Glossareintrag[] => {
178 const treffer = vorbereitet
179 .map(({ eintrag, ausdruck }) => {
180 /* `lastIndex` zurücksetzen: Der Ausdruck ist global und wird über
181 alle Aufrufe hinweg wiederverwendet. */
182 ausdruck.lastIndex = 0;
183 return { eintrag, formen: [...text.matchAll(ausdruck)].map((m) => m[0]) };
184 })
185 .filter(({ formen }) => formen.length > 0);
186
187 return eindeutigMachen(treffer).sort((a, b) => a.begriff.localeCompare(b.begriff, 'de'));
188 },
189 };
190 }
191
192 /** Leeres Glossar – die Anwendung läuft auch ohne. */
193 export const GLOSSAR_LEER: Glossar = Object.freeze({
194 meta: Object.freeze({
195 version: 0,
196 stand: '',
197 gesetzesstand: Object.freeze({}),
198 hinweis: '',
199 }),
200 eintraege: Object.freeze([]),
201 });