Baustein 2: Inhalte einlesen und pruefen, Handouts, Suche, Glossar

Die fachlichen Inhalte liegen als Dateien unter content/ und werden beim
Start eingelesen, geprueft und in den Speicher geladen.

- Inhaltslader mit Zod-Schemata und Querverweispruefung
- Fehlermeldungen nennen Datei und Zeile, gesammelt statt beim ersten
  Fehler geworfen; die Anwendung startet bei Fehlern nicht
- npm run inhalte:pruefen prueft ohne Start
- Handout-Ansicht mit festem Aufbau, Uebersicht nach Themenbloecken
- Volltextsuche mit Gewichtung und Trefferausschnitt
- Gelesen-Markierung je Handout
- Glossar mit Suche und automatischer Verlinkung in den Handouts
- Beispielinhalte: 4 Wochen, 12 Handouts, 24 Merkkarten, 4 Aufgaben,
  4 Checklisten, 2 Lernzielkontrollen, 24 Glossareintraege
- rahmenplan.yaml aus FIAusbV 2020 und KMK-Rahmenlehrplan, mit Fundstellen
- gray-matter entfernt, YAML-Kopf wird selbst herausgeloest
- npm run screenshots nimmt die Bilder fuer die Anleitung auf
- 39 neue Tests, insgesamt 68

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-10 07:56:38 +02:00
co-authored by Claude Opus 5
parent e856349cd4
commit 1af5613e9b
70 changed files with 4779 additions and 124 deletions
+82
View File
@@ -0,0 +1,82 @@
import { normalisiere, type Handout, type Inhalte } from './laden';
/**
* Volltextsuche über die Handouts.
*
* Bewusst kein Suchindex und keine Bibliothek: Bei 52 Handouts ist ein
* Durchlauf über alle Texte im Mikrosekundenbereich, und ein Suchindex wäre
* eine zweite Wahrheit, die man pflegen müsste.
*/
export type Fundstueck = {
handout: Handout;
punkte: number;
ausschnitt: string;
};
/** Zerlegt die Eingabe in Wörter; Anführungszeichen halten eine Wortgruppe zusammen. */
export function suchbegriffe(eingabe: string): string[] {
const treffer = eingabe.match(/"[^"]+"|\S+/g) ?? [];
return treffer.map((t) => normalisiere(t.replace(/^"|"$/g, ''))).filter((t) => t.length > 1);
}
export function sucheHandouts(alle: Inhalte, eingabe: string, grenze = 30): Fundstueck[] {
const begriffe = suchbegriffe(eingabe);
if (begriffe.length === 0) return [];
const gefunden: Fundstueck[] = [];
for (const handout of alle.handouts) {
const titel = normalisiere(`${handout.id} ${handout.titel}`);
const worum = normalisiere(handout.worum);
const merksatz = normalisiere(handout.merksatz);
let punkte = 0;
let alleEnthalten = true;
for (const begriff of begriffe) {
let treffer = 0;
if (titel.includes(begriff)) treffer += 10;
if (worum.includes(begriff)) treffer += 5;
if (merksatz.includes(begriff)) treffer += 4;
if (handout.suchtext.includes(begriff)) treffer += 1;
if (treffer === 0) {
alleEnthalten = false;
break;
}
punkte += treffer;
}
if (!alleEnthalten) continue;
gefunden.push({ handout, punkte, ausschnitt: ausschnittUm(handout, begriffe[0]!) });
}
return gefunden
.sort((a, b) => b.punkte - a.punkte || a.handout.id.localeCompare(b.handout.id, 'de-DE'))
.slice(0, grenze);
}
/**
* Ein Stück Text rund um den ersten Treffer, damit man in der Trefferliste
* sieht, warum etwas gefunden wurde.
*/
function ausschnittUm(handout: Handout, begriff: string, laenge = 160): string {
const stelle = handout.suchtext.indexOf(begriff);
if (stelle === -1) return handout.worum;
// Der normalisierte Text hat dieselbe Länge wie das Original, weil nur
// kleingeschrieben und Leerraum vereinheitlicht wird – bis auf Umlaute.
// Für einen Ausschnitt reicht das allemal.
const roh = `${handout.id} ${handout.titel} ${handout.worum} ${handout.merksatz} ${handout.rumpf}`
.replace(/\s+/g, ' ')
.trim();
const mitte = Math.min(stelle, Math.max(0, roh.length - 1));
const von = Math.max(0, mitte - Math.floor(laenge / 3));
const bis = Math.min(roh.length, von + laenge);
const teil = roh.slice(von, bis).trim();
return `${von > 0 ? '… ' : ''}${teil}${bis < roh.length ? ' …' : ''}`;
}