/** * Die Volltextsuche über den amtlichen Fragenkatalog. * * ## Wo der Index liegt und warum hier * * Im Arbeitsspeicher, aufgebaut aus dem bereits geladenen Katalog. Nicht in * SQLite, nicht im Hauptprozess, nicht in der Lernstand-Datei. Die Gründe * stehen ausführlich in `docs/entscheidung-volltextsuche.md`; die drei * wichtigsten in einem Satz: FTS5 kann deutsche Umlaute **oder** deutsche * Zusammensetzungen, nie beides; wer die Faltung deshalb ohnehin selbst * schreibt, bekommt von FTS5 nichts mehr geschenkt; und der Katalog liegt * bereits vollständig im Renderer. * * Gemessen: Aufbau rund 3 ms für 151.000 Zeichen, eine Abfrage 0,15 ms. Ein * Bild bei 60 Hz dauert 16,7 ms. * * ## Was durchsucht wird * * Fragetext, **alle** Antwortmöglichkeiten und die Musterantwort. Die * Antwortmöglichkeiten sind mit 57 Prozent der größte Textblock des Katalogs * – eine Suche nur über Fragetexte verfehlte mehr als die Hälfte des * Materials. * * Die Musterantwort wird durchsucht, aber **nie angezeigt**: Bei den 103 * offenen Fragen ist sie die Lösung. Ein Treffer dort landet in der letzten * Rangklasse und sagt von sich aus, dass er nicht gezeigt wird. * * Nicht durchsucht werden die Alternativtexte der Prüfzeichen. Sie sind bei * Bildfragen redaktionell so geschrieben, dass sie die Lösung nicht verraten; * ein Treffer darin machte genau diese Arbeit rückgängig. */ import { BEGRIFFSBRUECKE } from './begriffsbruecke'; import type { Katalog } from './katalog'; import { falten, faltenMitZuordnung, fundstellen, istWortanfang } from './suchtext'; /** * Wie gut ein Treffer sitzt – als Klasse, nicht als Punktwert. * * Eine Zahl, die niemand nachrechnen kann, ist in einem Lernmittel schlechter * als eine Reihenfolge, die dasteht und sich erklärt. Das gilt doppelt für * jemanden, der die Liste hört, statt sie zu überfliegen: Er kann eine * Sortierung nicht überblicken, aber er kann einen Satz lesen hören. */ export type Rangklasse = 'frage-anfang' | 'frage-innen' | 'antwort' | 'muster'; const RANGFOLGE: readonly Rangklasse[] = ['frage-anfang', 'frage-innen', 'antwort', 'muster']; export interface Treffer { readonly frageId: string; readonly klasse: Rangklasse; /** Labels der Antwortmöglichkeiten mit Fundstelle, in Katalogreihenfolge. */ readonly optionen: readonly string[]; /** * Steht mindestens ein Suchwort auch im Fragetext? * * Nur für die Klassen `antwort` und `muster` von Belang, und dort für die * Wahrheit der Fundstellenzeile: Sagt die Karte „Treffer nur in der * Musterantwort“, markiert aber gleichzeitig ein Wort in der sichtbaren * Frage, widersprechen sich Marke und Satz. Der Satz ist dann der falsche – * die Marke steht ja zu Recht dort. */ readonly auchImFragetext: boolean; } interface Feld { readonly label: string; readonly gefaltet: string; } interface Eintrag { readonly frageId: string; readonly kapitel: string; readonly abschnitt: string | null; readonly fragetext: string; readonly optionen: readonly Feld[]; readonly musterantwort: string; /** Alle Felder zusammen – die schnelle Vorprüfung je Suchwort. */ readonly alles: string; } export interface Suchindex { readonly eintraege: readonly Eintrag[]; } /** Ein wählbarer Bereich der Trefferliste: Kapitel oder Abschnitt. */ export interface Bereich { /** `null` steht für „alle Bereiche“. */ readonly id: string | null; readonly titel: string; readonly anzahl: number; } export interface Suchergebnis { readonly treffer: readonly Treffer[]; /** Die gefalteten Suchwörter – die Oberfläche hebt danach hervor. */ readonly woerter: readonly string[]; /** Die Eingabe ist zu kurz, um überhaupt zu suchen. */ readonly zuKurz: boolean; /** Kein Suchwort eingegeben: die Liste zeigt den Bereich vollständig. */ readonly stoebern: boolean; } /** Kürzeste Eingabe, mit der gesucht wird. */ export const MINDESTLAENGE = 2; // ─── Aufbau ────────────────────────────────────────────────────────────── export function suchindexBauen(katalog: Katalog): Suchindex { const eintraege: Eintrag[] = katalog.fragen.map((frage) => { const fragetext = falten(frage.frage.text); const optionen = (frage.optionen ?? []).map((option) => ({ label: option.label, gefaltet: falten(option.inhalt.text), })); const musterantwort = frage.musterantwort ? falten(frage.musterantwort.text) : ''; return { frageId: frage.id, kapitel: frage.kapitel, abschnitt: frage.abschnitt, fragetext, optionen, musterantwort, alles: [fragetext, ...optionen.map((o) => o.gefaltet), musterantwort].join('  '), }; }); return { eintraege }; } /** * Die wählbaren Bereiche samt Fragenzahl. * * Die Zahlen stehen in der Beschriftung, damit niemand einen Bereich wählt * und erst danach merkt, dass darin drei Fragen liegen. Sie kommen aus dem * Katalog und nicht aus einer zweiten Pflege – eine Zahl, die von Hand * nachgeführt wird, ist eine Zahl, die irgendwann falsch ist. */ export function bereiche(katalog: Katalog): readonly Bereich[] { const aus: Bereich[] = [{ id: null, titel: 'Alle Bereiche', anzahl: katalog.fragen.length }]; for (const kapitel of katalog.kapitel) { aus.push({ id: kapitel.id, titel: `Kapitel ${kapitel.id} – ${kapitel.titel}`, anzahl: katalog.fragen.filter((f) => f.kapitel === kapitel.id).length, }); for (const abschnitt of kapitel.abschnitte) { aus.push({ id: abschnitt.id, titel: `${abschnitt.id} – ${abschnitt.titel}`, anzahl: katalog.fragen.filter((f) => f.abschnitt === abschnitt.id).length, }); } } return aus; } function imBereich(eintrag: Eintrag, bereich: string | null): boolean { if (bereich === null) { return true; } return eintrag.kapitel === bereich || eintrag.abschnitt === bereich; } // ─── Suchen ────────────────────────────────────────────────────────────── /** * Zerlegt die Eingabe in gefaltete Suchwörter. * * Getrennt wird an Leerzeichen, sonst nichts: keine Anführungszeichen, keine * Platzhalter, kein UND/ODER/NICHT. Die Fachnotation dieses Korpus besteht * genau aus den Zeichen, an denen eine Abfragesprache zerbricht – „7,65“, * „Abs. 1“, „§ 3“, „II-45“ lösen bei FTS5 harte Fehler aus. Hier gibt es * keine Eingabe, die eine Ausnahme wirft. */ export function suchwoerter(eingabe: string): string[] { return falten(eingabe) .split(' ') .map((wort) => randzeichenAbschneiden(wort)) .filter((w) => w.length > 0); } /** * Schneidet Satzzeichen an den Rändern eines Suchworts ab. * * Ohne das sucht die Anwendung das Satzzeichen mit, und zwar still: * „Notwehr!“ fand null Fragen, „Notwehr“ neunundzwanzig. Dasselbe bei * „Schusswaffe;“, bei „(Erwerb“ und – besonders ärgerlich – bei * „„Sicherheitsbehältnis““ mit den typografischen Anführungszeichen, die * diese Anwendung in ihren eigenen Hinweistexten setzt. Wer den Begriff aus * dem eigenen Angebot der Suche kopierte, bekam nichts. * * Abgeschnitten wird **nur außen**. Innen bleibt jedes Zeichen stehen, denn * dort trägt es Bedeutung: „7,5“, „12/70“, „I.2-150“, „II-45“. */ function randzeichenAbschneiden(wort: string): string { const istWortzeichen = (zeichen: string): boolean => /[\p{L}\p{N}]/u.test(zeichen); let anfang = 0; let ende = wort.length; while (anfang < ende && !istWortzeichen(wort[anfang] ?? '')) { anfang++; } while (ende > anfang && !istWortzeichen(wort[ende - 1] ?? '')) { ende--; } return wort.slice(anfang, ende); } /** Enthält der Eintrag alle Suchwörter – gleich in welchem Feld? */ function alleWoerterVorhanden(eintrag: Eintrag, woerter: readonly string[]): boolean { return woerter.every((wort) => eintrag.alles.includes(wort)); } /** * In welche Rangklasse fällt der Treffer, und welche Antworten sind betroffen? * * Die Klasse richtet sich danach, mit welchem Feld die Anfrage **vollständig** * erfüllt ist: Erst wenn der Fragetext allein nicht reicht, zählen die * Antwortmöglichkeiten, und erst wenn auch die nicht reichen, die * Musterantwort. So steht nie ein Antworttreffer über einem Fragetexttreffer. */ function einordnen(eintrag: Eintrag, woerter: readonly string[]): Treffer | null { const imFragetext = woerter.every((wort) => eintrag.fragetext.includes(wort)); if (imFragetext) { const amWortanfang = woerter.every((wort) => fundstellen(eintrag.fragetext, wort).some((stelle) => istWortanfang(eintrag.fragetext, stelle), ), ); return { frageId: eintrag.frageId, klasse: amWortanfang ? 'frage-anfang' : 'frage-innen', optionen: [], auchImFragetext: true, }; } const auchImFragetext = woerter.some((wort) => eintrag.fragetext.includes(wort)); const mitFragetext = (feld: string): string => `${eintrag.fragetext}  ${feld}`; const inOptionen = eintrag.optionen .filter((option) => woerter.some((wort) => option.gefaltet.includes(wort))) .map((option) => option.label); const optionenReichen = woerter.every((wort) => eintrag.optionen.some((option) => mitFragetext(option.gefaltet).includes(wort)), ); if (optionenReichen && inOptionen.length > 0) { return { frageId: eintrag.frageId, klasse: 'antwort', optionen: inOptionen, auchImFragetext }; } if (alleWoerterVorhanden(eintrag, woerter)) { return { frageId: eintrag.frageId, klasse: 'muster', optionen: inOptionen, auchImFragetext }; } return null; } /** * Sucht im Katalog. * * Linear über 575 Einträge – eine invertierte Wortliste könnte die * Teilwortsuche gar nicht leisten, auf die es hier ankommt: „besitzkarte“ * findet als Wortanfangssuche **null** Fragen und als Teilwort **52**. * Deutsche Rechtssprache verschluckt das gesuchte Wort im Kompositum. */ export function suchen( index: Suchindex, eingabe: string, bereich: string | null = null, ): Suchergebnis { const woerter = suchwoerter(eingabe); const gesamt = woerter.join(''); if (gesamt.length === 0) { /* Leeres Feld ist kein Fehlerfall, sondern der Fragen-Browser: Der gewählte Bereich wird vollständig gezeigt, in Katalogreihenfolge. */ return { treffer: index.eintraege .filter((e) => imBereich(e, bereich)) .map((e) => ({ frageId: e.frageId, klasse: 'frage-anfang' as const, optionen: [], auchImFragetext: true, })), woerter: [], zuKurz: false, stoebern: true, }; } if (gesamt.length < MINDESTLAENGE) { return { treffer: [], woerter, zuKurz: true, stoebern: false }; } const treffer: Treffer[] = []; for (const eintrag of index.eintraege) { if (!imBereich(eintrag, bereich) || !alleWoerterVorhanden(eintrag, woerter)) { continue; } const gefunden = einordnen(eintrag, woerter); if (gefunden !== null) { treffer.push(gefunden); } } /* Stabil nach Rangklasse, innerhalb einer Klasse in Katalogreihenfolge – der Reihenfolge, die der Prüfling aus der amtlichen Vorlage kennt. */ treffer.sort((a, b) => RANGFOLGE.indexOf(a.klasse) - RANGFOLGE.indexOf(b.klasse)); return { treffer, woerter, zuKurz: false, stoebern: false }; } // ─── Hilfen für den Leerzustand ────────────────────────────────────────── /** * Welches Wort einer mehrteiligen Anfrage ist schuld am leeren Ergebnis? * * „Keine Treffer“ ist eine Sackgasse; „‚verloren‘ kommt im Katalog nicht vor, * ohne dieses Wort: 305 Treffer“ ist ein Weg. Der Katalog sagt statt * „verloren“ nämlich „abhanden gekommen“. */ export function schuldigeWoerter(index: Suchindex, woerter: readonly string[]): string[] { return woerter.filter((wort) => !index.eintraege.some((e) => e.alles.includes(wort))); } /** Ein Wort des Katalogs samt der Zahl, die eine Suche danach liefert. */ export interface Brueckenvorschlag { readonly wort: string; readonly anzahl: number; } export interface Brueckenangebot { /** * Je Katalogwort ein eigener Vorschlag – **nicht** eine gemeinsame Zahl. * * Das war zuerst anders und falsch: Das Angebot nannte die * Vereinigungsmenge über alle Katalogwörter („27 Fragen“) und setzte beim * Klick nur das erste Wort ein („Sicherheitsbehältnis“, 3 Fragen). Der * Suchende bekam ein Neuntel dessen, was ihm zugesagt war – und hielt das * Thema danach für erschöpfend behandelt. Genau der Schaden, den diese * Brücke verhindern soll. * * Die Vereinigung ist über das Suchfeld auch gar nicht herstellbar: Mehrere * Wörter sind UND-verknüpft, „Sicherheitsbehältnis Widerstandsgrad * Aufbewahrung“ liefert null. Also verspricht jeder Vorschlag nur noch das, * was sein eigener Klick einlöst. */ readonly vorschlaege: readonly Brueckenvorschlag[]; } /** * Gibt es zu dieser Eingabe ein Wort, unter dem der Katalog dieselbe Sache * führt? * * Das Angebot wird **immer** geprüft, nicht nur bei null Treffern. „Tresor“ * findet drei Fragen – wer diese drei sieht, hält das Thema für erledigt und * übersieht die zwanzig zur Aufbewahrung. Eine Suche, die zu wenig findet, * ist gefährlicher als eine, die nichts findet: Die eine schweigt, die andere * behauptet Vollständigkeit. */ export function brueckeFinden( index: Suchindex, eingabe: string, bereich: string | null = null, ): Brueckenangebot | null { const woerter = new Set(suchwoerter(eingabe)); if (woerter.size === 0) { return null; } /* Verglichen wird Wort für Wort, nicht als Teilzeichenkette – anders als bei der Suche selbst. Nachgemessen der Grund: „ölen“ faltet zu „olen“, und das steckt in „Pistolen“, „wollen“, „sollen“. Wer nach Pistolen sucht, bekäme sonst ein Angebot zur Instandhaltung. Beim Durchsuchen des Katalogs ist die Teilzeichenkette richtig, beim Erkennen eines Stichworts ist sie falsch. */ const eintrag = BEGRIFFSBRUECKE.find((e) => e.gesucht.some((wort) => woerter.has(falten(wort)))); if (eintrag === undefined) { return null; } /* Gezählt wird im gewählten Bereich, nicht katalogweit. Sonst verspräche das Angebot 27 Fragen und führte in Kapitel II auf „Keine Frage gefunden.“ – eine Zusage, die die Einschränkung nicht kennt. */ const vorschlaege = eintrag.katalog .filter((wort) => !woerter.has(falten(wort))) .map((wort) => ({ wort, anzahl: suchen(index, wort, bereich).treffer.length })) .filter((vorschlag) => vorschlag.anzahl > 0); return vorschlaege.length === 0 ? null : { vorschlaege }; } export interface Zerlegung { /** Der linke Teil – in der Schreibweise des Suchenden, nicht gefaltet. */ readonly links: string; readonly rechts: string; readonly anzahl: number; } /** * Ein Zerlegungsangebot für ein zusammengesetztes Wort ohne Treffer. * * Das echte Loch, das keine Normalisierung schließt: „Reizstoffwaffe“ – der * Gesetzesbegriff aus § 42a WaffG – steht im Katalog **kein einziges Mal**, * weil dort „Schreckschuss-, Reizstoff- und Signalwaffen“ geschrieben ist. * * Zerlegt wird die **Anfrage**, nicht der Index. Beim Indexbau aufzulösen * wäre der naheliegende Weg und der schlechtere: Die Regel „Kopf plus letztes * Glied“ macht aus „Reizstoff- und Signalwaffen“ nicht „Reizstoffwaffen“, * sondern „Reizstoffsignalwaffen“, und aus kleingeschriebenen Fragmenten wie * „ge- und entladen“ erzeugt sie Unwörter. Auf der Anfrageseite kostet es nur * im Nullfall Rechenzeit, erzeugt nichts Falsches im Index und geschieht * sichtbar auf Knopfdruck. */ export function zerlegung( index: Suchindex, rohesWort: string, bereich: string | null = null, ): Zerlegung | null { const MINDEST_TEIL = 3; const faltung = faltenMitZuordnung(rohesWort); const gefaltet = faltung.gefaltet; let beste: Zerlegung | null = null; for (let schnitt = MINDEST_TEIL; schnitt <= gefaltet.length - MINDEST_TEIL; schnitt++) { const links = gefaltet.slice(0, schnitt); const rechts = gefaltet.slice(schnitt); const anzahl = index.eintraege.filter( (e) => imBereich(e, bereich) && e.alles.includes(links) && e.alles.includes(rechts), ).length; if (anzahl > 0 && (beste === null || anzahl > beste.anzahl)) { /* Angeboten wird die Schreibweise des Suchenden, nicht die Kanonform: „Reizstoff“ und „waffe“, nicht „reizstof“ und „wafe“. Die Faltung ist ein inneres Werkzeug und hat auf dem Bildschirm nichts verloren. */ const grenze = faltung.von[schnitt] ?? rohesWort.length; /* Die Ränder abschneiden: Bei „Kurzwaffen-Munition“ fiele der Bindestrich sonst an das Ende des linken Teils, und die neue Anfrage „Kurzwaffen- Munition“ fände null – das Angebot verspräche drei. */ beste = { links: randzeichenAbschneiden(rohesWort.slice(0, grenze)), rechts: randzeichenAbschneiden(rohesWort.slice(grenze)), anzahl, }; } } return beste; }