waffensachkunde

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

/ app src shared fragensuche.ts

17,2 KB Rohdatei
app/src/shared/fragensuche.ts — 449 Zeilen
1 /**
2 * Die Volltextsuche über den amtlichen Fragenkatalog.
3 *
4 * ## Wo der Index liegt und warum hier
5 *
6 * Im Arbeitsspeicher, aufgebaut aus dem bereits geladenen Katalog. Nicht in
7 * SQLite, nicht im Hauptprozess, nicht in der Lernstand-Datei. Die Gründe
8 * stehen ausführlich in `docs/entscheidung-volltextsuche.md`; die drei
9 * wichtigsten in einem Satz: FTS5 kann deutsche Umlaute **oder** deutsche
10 * Zusammensetzungen, nie beides; wer die Faltung deshalb ohnehin selbst
11 * schreibt, bekommt von FTS5 nichts mehr geschenkt; und der Katalog liegt
12 * bereits vollständig im Renderer.
13 *
14 * Gemessen: Aufbau rund 3 ms für 151.000 Zeichen, eine Abfrage 0,15 ms. Ein
15 * Bild bei 60 Hz dauert 16,7 ms.
16 *
17 * ## Was durchsucht wird
18 *
19 * Fragetext, **alle** Antwortmöglichkeiten und die Musterantwort. Die
20 * Antwortmöglichkeiten sind mit 57 Prozent der größte Textblock des Katalogs
21 * – eine Suche nur über Fragetexte verfehlte mehr als die Hälfte des
22 * Materials.
23 *
24 * Die Musterantwort wird durchsucht, aber **nie angezeigt**: Bei den 103
25 * offenen Fragen ist sie die Lösung. Ein Treffer dort landet in der letzten
26 * Rangklasse und sagt von sich aus, dass er nicht gezeigt wird.
27 *
28 * Nicht durchsucht werden die Alternativtexte der Prüfzeichen. Sie sind bei
29 * Bildfragen redaktionell so geschrieben, dass sie die Lösung nicht verraten;
30 * ein Treffer darin machte genau diese Arbeit rückgängig.
31 */
32
33 import { BEGRIFFSBRUECKE } from './begriffsbruecke';
34 import type { Katalog } from './katalog';
35 import { falten, faltenMitZuordnung, fundstellen, istWortanfang } from './suchtext';
36
37 /**
38 * Wie gut ein Treffer sitzt – als Klasse, nicht als Punktwert.
39 *
40 * Eine Zahl, die niemand nachrechnen kann, ist in einem Lernmittel schlechter
41 * als eine Reihenfolge, die dasteht und sich erklärt. Das gilt doppelt für
42 * jemanden, der die Liste hört, statt sie zu überfliegen: Er kann eine
43 * Sortierung nicht überblicken, aber er kann einen Satz lesen hören.
44 */
45 export type Rangklasse = 'frage-anfang' | 'frage-innen' | 'antwort' | 'muster';
46
47 const RANGFOLGE: readonly Rangklasse[] = ['frage-anfang', 'frage-innen', 'antwort', 'muster'];
48
49 export interface Treffer {
50 readonly frageId: string;
51 readonly klasse: Rangklasse;
52 /** Labels der Antwortmöglichkeiten mit Fundstelle, in Katalogreihenfolge. */
53 readonly optionen: readonly string[];
54 /**
55 * Steht mindestens ein Suchwort auch im Fragetext?
56 *
57 * Nur für die Klassen `antwort` und `muster` von Belang, und dort für die
58 * Wahrheit der Fundstellenzeile: Sagt die Karte „Treffer nur in der
59 * Musterantwort“, markiert aber gleichzeitig ein Wort in der sichtbaren
60 * Frage, widersprechen sich Marke und Satz. Der Satz ist dann der falsche –
61 * die Marke steht ja zu Recht dort.
62 */
63 readonly auchImFragetext: boolean;
64 }
65
66 interface Feld {
67 readonly label: string;
68 readonly gefaltet: string;
69 }
70
71 interface Eintrag {
72 readonly frageId: string;
73 readonly kapitel: string;
74 readonly abschnitt: string | null;
75 readonly fragetext: string;
76 readonly optionen: readonly Feld[];
77 readonly musterantwort: string;
78 /** Alle Felder zusammen – die schnelle Vorprüfung je Suchwort. */
79 readonly alles: string;
80 }
81
82 export interface Suchindex {
83 readonly eintraege: readonly Eintrag[];
84 }
85
86 /** Ein wählbarer Bereich der Trefferliste: Kapitel oder Abschnitt. */
87 export interface Bereich {
88 /** `null` steht für „alle Bereiche“. */
89 readonly id: string | null;
90 readonly titel: string;
91 readonly anzahl: number;
92 }
93
94 export interface Suchergebnis {
95 readonly treffer: readonly Treffer[];
96 /** Die gefalteten Suchwörter – die Oberfläche hebt danach hervor. */
97 readonly woerter: readonly string[];
98 /** Die Eingabe ist zu kurz, um überhaupt zu suchen. */
99 readonly zuKurz: boolean;
100 /** Kein Suchwort eingegeben: die Liste zeigt den Bereich vollständig. */
101 readonly stoebern: boolean;
102 }
103
104 /** Kürzeste Eingabe, mit der gesucht wird. */
105 export const MINDESTLAENGE = 2;
106
107 // ─── Aufbau ──────────────────────────────────────────────────────────────
108
109 export function suchindexBauen(katalog: Katalog): Suchindex {
110 const eintraege: Eintrag[] = katalog.fragen.map((frage) => {
111 const fragetext = falten(frage.frage.text);
112 const optionen = (frage.optionen ?? []).map((option) => ({
113 label: option.label,
114 gefaltet: falten(option.inhalt.text),
115 }));
116 const musterantwort = frage.musterantwort ? falten(frage.musterantwort.text) : '';
117 return {
118 frageId: frage.id,
119 kapitel: frage.kapitel,
120 abschnitt: frage.abschnitt,
121 fragetext,
122 optionen,
123 musterantwort,
124 alles: [fragetext, ...optionen.map((o) => o.gefaltet), musterantwort].join('  '),
125 };
126 });
127 return { eintraege };
128 }
129
130 /**
131 * Die wählbaren Bereiche samt Fragenzahl.
132 *
133 * Die Zahlen stehen in der Beschriftung, damit niemand einen Bereich wählt
134 * und erst danach merkt, dass darin drei Fragen liegen. Sie kommen aus dem
135 * Katalog und nicht aus einer zweiten Pflege – eine Zahl, die von Hand
136 * nachgeführt wird, ist eine Zahl, die irgendwann falsch ist.
137 */
138 export function bereiche(katalog: Katalog): readonly Bereich[] {
139 const aus: Bereich[] = [{ id: null, titel: 'Alle Bereiche', anzahl: katalog.fragen.length }];
140 for (const kapitel of katalog.kapitel) {
141 aus.push({
142 id: kapitel.id,
143 titel: `Kapitel ${kapitel.id} – ${kapitel.titel}`,
144 anzahl: katalog.fragen.filter((f) => f.kapitel === kapitel.id).length,
145 });
146 for (const abschnitt of kapitel.abschnitte) {
147 aus.push({
148 id: abschnitt.id,
149 titel: `${abschnitt.id} – ${abschnitt.titel}`,
150 anzahl: katalog.fragen.filter((f) => f.abschnitt === abschnitt.id).length,
151 });
152 }
153 }
154 return aus;
155 }
156
157 function imBereich(eintrag: Eintrag, bereich: string | null): boolean {
158 if (bereich === null) {
159 return true;
160 }
161 return eintrag.kapitel === bereich || eintrag.abschnitt === bereich;
162 }
163
164 // ─── Suchen ──────────────────────────────────────────────────────────────
165
166 /**
167 * Zerlegt die Eingabe in gefaltete Suchwörter.
168 *
169 * Getrennt wird an Leerzeichen, sonst nichts: keine Anführungszeichen, keine
170 * Platzhalter, kein UND/ODER/NICHT. Die Fachnotation dieses Korpus besteht
171 * genau aus den Zeichen, an denen eine Abfragesprache zerbricht – „7,65“,
172 * „Abs. 1“, „§ 3“, „II-45“ lösen bei FTS5 harte Fehler aus. Hier gibt es
173 * keine Eingabe, die eine Ausnahme wirft.
174 */
175 export function suchwoerter(eingabe: string): string[] {
176 return falten(eingabe)
177 .split(' ')
178 .map((wort) => randzeichenAbschneiden(wort))
179 .filter((w) => w.length > 0);
180 }
181
182 /**
183 * Schneidet Satzzeichen an den Rändern eines Suchworts ab.
184 *
185 * Ohne das sucht die Anwendung das Satzzeichen mit, und zwar still:
186 * „Notwehr!“ fand null Fragen, „Notwehr“ neunundzwanzig. Dasselbe bei
187 * „Schusswaffe;“, bei „(Erwerb“ und – besonders ärgerlich – bei
188 * „„Sicherheitsbehältnis““ mit den typografischen Anführungszeichen, die
189 * diese Anwendung in ihren eigenen Hinweistexten setzt. Wer den Begriff aus
190 * dem eigenen Angebot der Suche kopierte, bekam nichts.
191 *
192 * Abgeschnitten wird **nur außen**. Innen bleibt jedes Zeichen stehen, denn
193 * dort trägt es Bedeutung: „7,5“, „12/70“, „I.2-150“, „II-45“.
194 */
195 function randzeichenAbschneiden(wort: string): string {
196 const istWortzeichen = (zeichen: string): boolean => /[\p{L}\p{N}]/u.test(zeichen);
197 let anfang = 0;
198 let ende = wort.length;
199 while (anfang < ende && !istWortzeichen(wort[anfang] ?? '')) {
200 anfang++;
201 }
202 while (ende > anfang && !istWortzeichen(wort[ende - 1] ?? '')) {
203 ende--;
204 }
205 return wort.slice(anfang, ende);
206 }
207
208 /** Enthält der Eintrag alle Suchwörter – gleich in welchem Feld? */
209 function alleWoerterVorhanden(eintrag: Eintrag, woerter: readonly string[]): boolean {
210 return woerter.every((wort) => eintrag.alles.includes(wort));
211 }
212
213 /**
214 * In welche Rangklasse fällt der Treffer, und welche Antworten sind betroffen?
215 *
216 * Die Klasse richtet sich danach, mit welchem Feld die Anfrage **vollständig**
217 * erfüllt ist: Erst wenn der Fragetext allein nicht reicht, zählen die
218 * Antwortmöglichkeiten, und erst wenn auch die nicht reichen, die
219 * Musterantwort. So steht nie ein Antworttreffer über einem Fragetexttreffer.
220 */
221 function einordnen(eintrag: Eintrag, woerter: readonly string[]): Treffer | null {
222 const imFragetext = woerter.every((wort) => eintrag.fragetext.includes(wort));
223
224 if (imFragetext) {
225 const amWortanfang = woerter.every((wort) =>
226 fundstellen(eintrag.fragetext, wort).some((stelle) =>
227 istWortanfang(eintrag.fragetext, stelle),
228 ),
229 );
230 return {
231 frageId: eintrag.frageId,
232 klasse: amWortanfang ? 'frage-anfang' : 'frage-innen',
233 optionen: [],
234 auchImFragetext: true,
235 };
236 }
237
238 const auchImFragetext = woerter.some((wort) => eintrag.fragetext.includes(wort));
239
240 const mitFragetext = (feld: string): string => `${eintrag.fragetext}  ${feld}`;
241 const inOptionen = eintrag.optionen
242 .filter((option) => woerter.some((wort) => option.gefaltet.includes(wort)))
243 .map((option) => option.label);
244
245 const optionenReichen = woerter.every((wort) =>
246 eintrag.optionen.some((option) => mitFragetext(option.gefaltet).includes(wort)),
247 );
248 if (optionenReichen && inOptionen.length > 0) {
249 return { frageId: eintrag.frageId, klasse: 'antwort', optionen: inOptionen, auchImFragetext };
250 }
251
252 if (alleWoerterVorhanden(eintrag, woerter)) {
253 return { frageId: eintrag.frageId, klasse: 'muster', optionen: inOptionen, auchImFragetext };
254 }
255 return null;
256 }
257
258 /**
259 * Sucht im Katalog.
260 *
261 * Linear über 575 Einträge – eine invertierte Wortliste könnte die
262 * Teilwortsuche gar nicht leisten, auf die es hier ankommt: „besitzkarte“
263 * findet als Wortanfangssuche **null** Fragen und als Teilwort **52**.
264 * Deutsche Rechtssprache verschluckt das gesuchte Wort im Kompositum.
265 */
266 export function suchen(
267 index: Suchindex,
268 eingabe: string,
269 bereich: string | null = null,
270 ): Suchergebnis {
271 const woerter = suchwoerter(eingabe);
272 const gesamt = woerter.join('');
273
274 if (gesamt.length === 0) {
275 /* Leeres Feld ist kein Fehlerfall, sondern der Fragen-Browser: Der
276 gewählte Bereich wird vollständig gezeigt, in Katalogreihenfolge. */
277 return {
278 treffer: index.eintraege
279 .filter((e) => imBereich(e, bereich))
280 .map((e) => ({
281 frageId: e.frageId,
282 klasse: 'frage-anfang' as const,
283 optionen: [],
284 auchImFragetext: true,
285 })),
286 woerter: [],
287 zuKurz: false,
288 stoebern: true,
289 };
290 }
291
292 if (gesamt.length < MINDESTLAENGE) {
293 return { treffer: [], woerter, zuKurz: true, stoebern: false };
294 }
295
296 const treffer: Treffer[] = [];
297 for (const eintrag of index.eintraege) {
298 if (!imBereich(eintrag, bereich) || !alleWoerterVorhanden(eintrag, woerter)) {
299 continue;
300 }
301 const gefunden = einordnen(eintrag, woerter);
302 if (gefunden !== null) {
303 treffer.push(gefunden);
304 }
305 }
306
307 /* Stabil nach Rangklasse, innerhalb einer Klasse in Katalogreihenfolge –
308 der Reihenfolge, die der Prüfling aus der amtlichen Vorlage kennt. */
309 treffer.sort((a, b) => RANGFOLGE.indexOf(a.klasse) - RANGFOLGE.indexOf(b.klasse));
310 return { treffer, woerter, zuKurz: false, stoebern: false };
311 }
312
313 // ─── Hilfen für den Leerzustand ──────────────────────────────────────────
314
315 /**
316 * Welches Wort einer mehrteiligen Anfrage ist schuld am leeren Ergebnis?
317 *
318 * „Keine Treffer“ ist eine Sackgasse; „‚verloren‘ kommt im Katalog nicht vor,
319 * ohne dieses Wort: 305 Treffer“ ist ein Weg. Der Katalog sagt statt
320 * „verloren“ nämlich „abhanden gekommen“.
321 */
322 export function schuldigeWoerter(index: Suchindex, woerter: readonly string[]): string[] {
323 return woerter.filter((wort) => !index.eintraege.some((e) => e.alles.includes(wort)));
324 }
325
326 /** Ein Wort des Katalogs samt der Zahl, die eine Suche danach liefert. */
327 export interface Brueckenvorschlag {
328 readonly wort: string;
329 readonly anzahl: number;
330 }
331
332 export interface Brueckenangebot {
333 /**
334 * Je Katalogwort ein eigener Vorschlag – **nicht** eine gemeinsame Zahl.
335 *
336 * Das war zuerst anders und falsch: Das Angebot nannte die
337 * Vereinigungsmenge über alle Katalogwörter („27 Fragen“) und setzte beim
338 * Klick nur das erste Wort ein („Sicherheitsbehältnis“, 3 Fragen). Der
339 * Suchende bekam ein Neuntel dessen, was ihm zugesagt war – und hielt das
340 * Thema danach für erschöpfend behandelt. Genau der Schaden, den diese
341 * Brücke verhindern soll.
342 *
343 * Die Vereinigung ist über das Suchfeld auch gar nicht herstellbar: Mehrere
344 * Wörter sind UND-verknüpft, „Sicherheitsbehältnis Widerstandsgrad
345 * Aufbewahrung“ liefert null. Also verspricht jeder Vorschlag nur noch das,
346 * was sein eigener Klick einlöst.
347 */
348 readonly vorschlaege: readonly Brueckenvorschlag[];
349 }
350
351 /**
352 * Gibt es zu dieser Eingabe ein Wort, unter dem der Katalog dieselbe Sache
353 * führt?
354 *
355 * Das Angebot wird **immer** geprüft, nicht nur bei null Treffern. „Tresor“
356 * findet drei Fragen – wer diese drei sieht, hält das Thema für erledigt und
357 * übersieht die zwanzig zur Aufbewahrung. Eine Suche, die zu wenig findet,
358 * ist gefährlicher als eine, die nichts findet: Die eine schweigt, die andere
359 * behauptet Vollständigkeit.
360 */
361 export function brueckeFinden(
362 index: Suchindex,
363 eingabe: string,
364 bereich: string | null = null,
365 ): Brueckenangebot | null {
366 const woerter = new Set(suchwoerter(eingabe));
367 if (woerter.size === 0) {
368 return null;
369 }
370
371 /*
372 Verglichen wird Wort für Wort, nicht als Teilzeichenkette – anders als bei
373 der Suche selbst. Nachgemessen der Grund: „ölen“ faltet zu „olen“, und das
374 steckt in „Pistolen“, „wollen“, „sollen“. Wer nach Pistolen sucht, bekäme
375 sonst ein Angebot zur Instandhaltung. Beim Durchsuchen des Katalogs ist die
376 Teilzeichenkette richtig, beim Erkennen eines Stichworts ist sie falsch.
377 */
378 const eintrag = BEGRIFFSBRUECKE.find((e) => e.gesucht.some((wort) => woerter.has(falten(wort))));
379 if (eintrag === undefined) {
380 return null;
381 }
382
383 /* Gezählt wird im gewählten Bereich, nicht katalogweit. Sonst verspräche
384 das Angebot 27 Fragen und führte in Kapitel II auf „Keine Frage
385 gefunden.“ – eine Zusage, die die Einschränkung nicht kennt. */
386 const vorschlaege = eintrag.katalog
387 .filter((wort) => !woerter.has(falten(wort)))
388 .map((wort) => ({ wort, anzahl: suchen(index, wort, bereich).treffer.length }))
389 .filter((vorschlag) => vorschlag.anzahl > 0);
390
391 return vorschlaege.length === 0 ? null : { vorschlaege };
392 }
393
394 export interface Zerlegung {
395 /** Der linke Teil – in der Schreibweise des Suchenden, nicht gefaltet. */
396 readonly links: string;
397 readonly rechts: string;
398 readonly anzahl: number;
399 }
400
401 /**
402 * Ein Zerlegungsangebot für ein zusammengesetztes Wort ohne Treffer.
403 *
404 * Das echte Loch, das keine Normalisierung schließt: „Reizstoffwaffe“ – der
405 * Gesetzesbegriff aus § 42a WaffG – steht im Katalog **kein einziges Mal**,
406 * weil dort „Schreckschuss-, Reizstoff- und Signalwaffen“ geschrieben ist.
407 *
408 * Zerlegt wird die **Anfrage**, nicht der Index. Beim Indexbau aufzulösen
409 * wäre der naheliegende Weg und der schlechtere: Die Regel „Kopf plus letztes
410 * Glied“ macht aus „Reizstoff- und Signalwaffen“ nicht „Reizstoffwaffen“,
411 * sondern „Reizstoffsignalwaffen“, und aus kleingeschriebenen Fragmenten wie
412 * „ge- und entladen“ erzeugt sie Unwörter. Auf der Anfrageseite kostet es nur
413 * im Nullfall Rechenzeit, erzeugt nichts Falsches im Index und geschieht
414 * sichtbar auf Knopfdruck.
415 */
416 export function zerlegung(
417 index: Suchindex,
418 rohesWort: string,
419 bereich: string | null = null,
420 ): Zerlegung | null {
421 const MINDEST_TEIL = 3;
422 const faltung = faltenMitZuordnung(rohesWort);
423 const gefaltet = faltung.gefaltet;
424 let beste: Zerlegung | null = null;
425
426 for (let schnitt = MINDEST_TEIL; schnitt <= gefaltet.length - MINDEST_TEIL; schnitt++) {
427 const links = gefaltet.slice(0, schnitt);
428 const rechts = gefaltet.slice(schnitt);
429 const anzahl = index.eintraege.filter(
430 (e) => imBereich(e, bereich) && e.alles.includes(links) && e.alles.includes(rechts),
431 ).length;
432 if (anzahl > 0 && (beste === null || anzahl > beste.anzahl)) {
433 /* Angeboten wird die Schreibweise des Suchenden, nicht die Kanonform:
434 „Reizstoff“ und „waffe“, nicht „reizstof“ und „wafe“. Die Faltung ist
435 ein inneres Werkzeug und hat auf dem Bildschirm nichts verloren. */
436 const grenze = faltung.von[schnitt] ?? rohesWort.length;
437 /* Die Ränder abschneiden: Bei „Kurzwaffen-Munition“ fiele der
438 Bindestrich sonst an das Ende des linken Teils, und die neue Anfrage
439 „Kurzwaffen- Munition“ fände null – das Angebot verspräche drei. */
440 beste = {
441 links: randzeichenAbschneiden(rohesWort.slice(0, grenze)),
442 rechts: randzeichenAbschneiden(rohesWort.slice(grenze)),
443 anzahl,
444 };
445 }
446 }
447
448 return beste;
449 }