/** * Beste Suggestions aus allen echten Berichtsheften. * - Extrahiert aus 1./2. Ausbildungsjahr * - Wirft Müll / KI-Kunstsätze raus * - Gewichtet nach Häufigkeit * - Max. ~25% stilgleiche Varianten */ import { existsSync, readFileSync, readdirSync, writeFileSync } from "fs"; import { dirname, join } from "path"; import { fileURLToPath } from "url"; import JSZip from "jszip"; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = join(__dirname, "..", ".."); const YEAR1 = join(ROOT, "1. Ausbildungsjahr"); const YEAR2 = join(ROOT, "2. Ausbildungsjahr"); const OUT_TS = join(__dirname, "..", "src", "data", "suggestions.ts"); const OUT_JSON = join(__dirname, "clean-phrases.json"); function listDocx(dir) { if (!existsSync(dir)) return []; return readdirSync(dir) .filter((f) => f.toLowerCase().endsWith(".docx") && !f.startsWith("~$")) .map((f) => join(dir, f)); } async function extractTextFromDocx(filePath) { try { const buf = readFileSync(filePath); const zip = await JSZip.loadAsync(buf); const file = zip.file("word/document.xml"); if (!file) return ""; let xml = await file.async("string"); xml = xml.replace(/<\/w:p>/g, "\n"); xml = xml.replace(/<[^>]+>/g, ""); return xml .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, '"') .replace(/&#\d+;/g, ""); } catch { return ""; } } function normalize(s) { return s .replace(/\u00a0/g, " ") .replace(/\s+/g, " ") .replace(/^[\s\-•–—]+/, "") .replace(/[.]+$/, (m) => (m.length > 1 ? "" : ".")) .trim(); } function normKey(s) { return normalize(s) .toLowerCase() .replace(/[.…]+$/g, "") .replace(/\s+/g, " "); } const JUNK = /^(abteilung|ausbildungsnachweis|für die zeit|name|datum|zweites|erstes|drittes|betriebliche|zuordnung|thema der woche|berufsschule|lfd\.?\s*nr|ausbilderin|nico|baumann|ferien|feiertag|urlaub|schultag|montag|dienstag|mittwoch|donnerstag|freitag)(\b|$)/i; const JUNK_ANY = /unterschrift|lfd\.?\s*nr|ausbildungsrahmenplan|…………|\.{5,}|^\d{5,}|43180|76835|88900|31115|60325|69850|zuordnung zum lernziel|tägliche tätigkeiten/i; const BAD_ARTIFICIAL = /\bNr\.\s*\d{1,4}\b|Rechnung\s+\d{3,}|Auftrag\s+\d{3,}|Unit\s+\d+|Aufgabe\s+\d+|Übungseinheit\s+\d+|Position\s+\d+|Charge\s+\d+|BWL:\s|BFK:\s|Buchhaltung:\s|Englisch:\s|Deutsch:\s|Controlling Kennzahlen|Optimierung der internen|Kommunikationsprozesse|ZUGFeRD|Proformarechnung für Export/i; function isSchoolLine(text) { const t = text.toLowerCase(); if ( /^(bwl|bfk|englisch|deutsch|excel|word|geschichte|politik|religion|textverarbeitung|projekt|introducing|polypol|kritik am|aufgaben zum|prüfungs|stillarbeit|besprechung der|beschaffung|erstellen einer|diagramme|briefe im|nachhaltigkeit|belastung|das-\/dass|optimale bestell|bestellverfahren|beispielrechnung|lagerkennzahlen|formatvorlage|funktion der|demograf|tarifvertrag|arbeitsschutz|listening|klassenarbeit)/i.test( t, ) ) return true; if ( /\b(bwl|englisch|excel|klassenarbeit|textverarbeitung|listening|polypol|projektarbeit|sozialabgaben|ergonomie)\b/i.test( t, ) && !/\b(kunde|rechnung geschrieben|termine|ablage|reifen|kasse|lagerware)\b/i.test(t) ) return true; return false; } function categorizeBetrieb(text) { const t = text.toLowerCase(); if (/rechnung|gutschrift|zahlungs|mahnung|avis|skonto|betrag|bar |ec-/.test(t)) return "Rechnungswesen"; if (/termin|kunde|kundin|telefon|bewertung|mobilit|angebot/.test(t)) return "Kunden"; if (/reifen|lager|lieferung|bestell|ware|artikel|wm |retour|einbuch/.test(t)) return "Lager"; if (/kasse|kassenbuch/.test(t)) return "Kasse"; if (/fahrzeug|abmeld|zulassung|versicherung|unfall|schaden|getriebe|mietfahrzeug|tüv|kilometer/.test(t)) return "Fahrzeuge"; if (/gefahren|gebracht|abgeholt|feuchtwangen|crailsheim|rothenburg|getränke/.test(t)) return "Fahrten"; if (/ablage|spül|auftrag|büro|cyber|guardey|kalender|archiv|e-mail|emails/.test(t)) return "Organisation"; return "Betrieb"; } function categorizeSchool(text) { const t = text.toLowerCase(); if (/bwl|kalkulation|beschaffung|lagerkenn|polypol|bruttoinlands|bestellverfahren|sozialabgaben/.test(t)) return "BWL"; if (/buchhaltung|umsatzsteuer|rechnung bestandteil/.test(t)) return "Buchhaltung"; if (/englisch|introducing|listening|dialogues|briefe im englischen|telefonate/.test(t)) return "Englisch"; if (/deutsch|das-\/dass|stellenanzeige|rechtschreib/.test(t)) return "Deutsch"; if (/excel|word|textverarbeitung|formatvorlage|diagramm|ergonomie/.test(t)) return "IT"; if (/geschichte|migration|medien|politik|tarif|arbeitsschutz|demograf/.test(t)) return "Gesellschaft"; if (/projekt|präsentation|kommunikation|meeting|checkliste|e-mail/.test(t)) return "BFK"; return "Berufsschule"; } function acceptActivity(text) { const t = normalize(text); if (t.length < 8 || t.length > 140) return false; if (JUNK.test(t) || JUNK_ANY.test(t) || BAD_ARTIFICIAL.test(t)) return false; if (!/[a-zäöüß]/i.test(t)) return false; if (/^bis\s+\d{2}\.\d{2}/i.test(t)) return false; // drop pure "Ferien" as activity if (/^ferien$/i.test(t)) return false; return true; } function acceptSchool(text) { const t = normalize(text); if (t.length < 4 || t.length > 120) return false; if (JUNK.test(t) || JUNK_ANY.test(t) || BAD_ARTIFICIAL.test(t)) return false; if (/^ferien$/i.test(t)) return false; if (!/[a-zäöüß]/i.test(t)) return false; return true; } function acceptTheme(text) { const t = normalize(text).replace(/,$/, ""); if (t.length < 3 || t.length > 55) return false; if (JUNK.test(t) || JUNK_ANY.test(t) || /^\d/.test(t) || t === "-") return false; if (/^(planung|vorschriften)$/i.test(t) && t.length < 5) return false; return true; } function parseSections(raw) { const lines = raw .split(/\n+/) .map((l) => normalize(l.replace(/^\d{5,}/, ""))) .filter(Boolean); const activities = []; const themes = []; const school = []; let section = "act"; for (const line of lines) { if (/thema der woche/i.test(line)) { section = "theme"; continue; } if (/berufsschule/i.test(line)) { section = "school"; continue; } if (/zuordnung|ausbildungsrahmenplan|betriebliche tätigkeiten/i.test(line)) { section = "fw"; continue; } if (/ausbildungsnachweis nr|für die zeit vom/i.test(line)) { section = "act"; continue; } if (section === "act" && acceptActivity(line) && !isSchoolLine(line)) { activities.push(normalize(line.replace(/\.$/, ""))); } else if (section === "theme" && acceptTheme(line)) { themes.push(normalize(line.replace(/,$/, ""))); } else if (section === "school" && acceptSchool(line)) { school.push(normalize(line)); } } return { activities, themes, school }; } function bump(map, text, file) { const key = normKey(text); if (!key) return; const prev = map.get(key) || { text, count: 0, sources: new Set() }; // prefer shorter/cleaner display text when similar if (text.length < prev.text.length) prev.text = text; prev.count += 1; prev.sources.add(file); map.set(key, prev); } /** Nur stilgleiche Varianten aus echten Mustern */ const VARIANT_RULES = [ { ifIncludes: "termine vereinbart", add: ["Termine telefonisch vereinbart", "Termine mit Kunden vereinbart"] }, { ifIncludes: "ablage einsortiert", add: ["Ablage sortiert", "Ablage organisiert"] }, { ifIncludes: "ablage verwaltet", add: ["Ablage archiviert"] }, { ifIncludes: "rechnungen geschrieben", add: ["Rechnungen erstellt", "Rechnungen fertiggestellt"] }, { ifIncludes: "rechnungen erstellt", add: ["Rechnungen geschrieben"] }, { ifIncludes: "getränke geholt", add: ["Getränke für die Belegschaft geholt", "Getränke besorgt"] }, { ifIncludes: "kasse gezählt", add: ["Kassenbuch geführt", "Kassenbuch geführt und die Kasse gezählt"] }, { ifIncludes: "kassenbuch geführt", add: ["Kasse gezählt"] }, { ifIncludes: "reifen bestellt", add: ["Reifen für Kunden bestellt"] }, { ifIncludes: "aufträge vorbereitet", add: ["Aufträge für die kommende Woche vorbereitet", "Aufträge bearbeitet"] }, { ifIncludes: "kunden per telefon betreut", add: ["Kundentelefonate durchgeführt zwecks Termine", "Telefonisch Termine vereinbart"] }, { ifIncludes: "spülmaschine", add: ["Spülmaschine ausgeräumt", "Spülmaschine ein- und ausgeräumt"] }, { ifIncludes: "mobilitätsgarant", add: ["Mobilitätsgarantien abgeschlossen", "Bosch-Mobilitätsgarantie abgeschlossen"] }, { ifIncludes: "bewertungen gesammelt", add: ["Bewertungen für autoservice.com gesammelt"] }, { ifIncludes: "lieferung", add: ["Lieferung angenommen und vorsortiert", "Lieferung überprüft, vorsortiert und eingebucht"] }, ]; async function main() { const files = [...listDocx(YEAR1), ...listDocx(YEAR2)]; console.log(`Parsing ${files.length} docx…`); const actMap = new Map(); const themeMap = new Map(); const schoolMap = new Map(); for (const file of files) { const name = file.split(/[/\\]/).pop(); const raw = await extractTextFromDocx(file); if (!raw) { console.warn("empty:", name); continue; } const { activities, themes, school } = parseSections(raw); for (const a of activities) bump(actMap, a, name); for (const t of themes) bump(themeMap, t, name); for (const s of school) bump(schoolMap, s, name); } // Drop very weak one-offs that look like noise (optional keep count>=1 but filter quality) let activities = [...actMap.values()] .filter((a) => acceptActivity(a.text) && !isSchoolLine(a.text)) .filter((a) => !BAD_ARTIFICIAL.test(a.text)) .sort((a, b) => b.count - a.count || a.text.localeCompare(b.text, "de")); let school = [...schoolMap.values()] .filter((s) => acceptSchool(s.text)) .filter((s) => isSchoolLine(s.text) || s.count >= 1) .filter((s) => !BAD_ARTIFICIAL.test(s.text)) .sort((a, b) => b.count - a.count || a.text.localeCompare(b.text, "de")); // Prefer school lines that look like school; demote misclassified school = school.filter((s) => { // if it looks strongly betrieb, skip if (/termine vereinbart|ablage |reifen bestellt|kasse gezählt|getränke/i.test(s.text)) return false; return true; }); const themes = [...themeMap.values()] .filter((t) => acceptTheme(t.text)) .sort((a, b) => b.count - a.count) .map((t) => t.text); // Boost multi-year / high-frequency for (const a of activities) { a.weight = a.count + (a.sources.size > 1 ? 1 : 0); if (a.count >= 4) a.weight += 2; if (a.count >= 8) a.weight += 3; } for (const s of school) { s.weight = s.count + (s.sources.size > 1 ? 1 : 0); if (s.count >= 3) s.weight += 2; } // Sparse variants from real patterns only const originalKeys = new Set(activities.map((a) => normKey(a.text))); const variants = []; for (const a of activities) { if (a.count < 2) continue; for (const rule of VARIANT_RULES) { if (!normKey(a.text).includes(rule.ifIncludes)) continue; for (const v of rule.add) { const k = normKey(v); if (originalKeys.has(k)) continue; if (variants.some((x) => normKey(x.text) === k)) continue; if (!acceptActivity(v)) continue; variants.push({ text: v, count: 0, weight: Math.max(1, a.weight - 2), sources: new Set(["variant"]), variant: true, }); originalKeys.add(k); } } } const maxVariants = Math.floor(activities.length * 0.28); const keptVariants = variants.slice(0, maxVariants); const allBetrieb = [...activities, ...keptVariants]; const suggestions = []; for (const a of allBetrieb) { suggestions.push({ text: a.text, type: "betrieb", category: categorizeBetrieb(a.text), weight: Math.max(1, a.weight || 1), original: !a.variant, }); } for (const s of school) { suggestions.push({ text: s.text, type: "schule", category: categorizeSchool(s.text), weight: Math.max(1, s.weight || 1), original: true, }); } // Final dedupe const seen = new Set(); const unique = []; for (const s of suggestions.sort((a, b) => b.weight - a.weight)) { const k = normKey(s.text) + "|" + s.type; if (seen.has(k)) continue; seen.add(k); unique.push(s); } const withIds = unique.map((s, i) => ({ id: i + 1, text: s.text, type: s.type, category: s.category, weight: s.weight, })); const betriebN = withIds.filter((s) => s.type === "betrieb").length; const schoolN = withIds.filter((s) => s.type === "schule").length; const originals = unique.filter((s) => s.type === "betrieb" && s.original).length; const originalShare = betriebN ? originals / betriebN : 0; writeFileSync( OUT_JSON, JSON.stringify( { activities: activities.map((a) => ({ text: a.text, count: a.count, weight: a.weight, })), school: school.map((s) => ({ text: s.text, count: s.count, weight: s.weight })), themes, stats: { files: files.length, betrieb: betriebN, school: schoolN, variants: keptVariants.length, originalShareBetrieb: Number(originalShare.toFixed(3)), }, }, null, 2, ), "utf8", ); const themeList = [...new Set(themes)].filter(Boolean); const ts = `/* AUTO-GENERATED – echte Berichtshefte · ${withIds.length} Vorschläge · ${new Date().toISOString().slice(0, 10)} */ export type SuggestionType = "betrieb" | "schule"; export type Suggestion = { id: number; text: string; type: SuggestionType; category: string; /** Häufigkeit/Qualität aus den Originalheften */ weight: number; }; export const SUGGESTIONS: Suggestion[] = ${JSON.stringify(withIds, null, 2)}; export const WEEK_THEMES: string[] = ${JSON.stringify(themeList, null, 2)}; export const CATEGORIES = [...new Set(SUGGESTIONS.map((s) => s.category))].sort(); export const SUGGESTION_STATS = ${JSON.stringify( { total: withIds.length, betrieb: betriebN, school: schoolN, variants: keptVariants.length, originalShareBetrieb: Number(originalShare.toFixed(3)), sourceFiles: files.length, }, null, 2, )} as const; `; writeFileSync(OUT_TS, ts, "utf8"); console.log("Wrote", OUT_TS); console.log( `total=${withIds.length} betrieb=${betriebN} schule=${schoolN} variants=${keptVariants.length} originalShare=${(originalShare * 100).toFixed(1)}%`, ); console.log("Top 15 betrieb:"); withIds .filter((s) => s.type === "betrieb") .slice(0, 15) .forEach((s) => console.log(` w${s.weight} ${s.text}`)); console.log("Top 10 schule:"); withIds .filter((s) => s.type === "schule") .slice(0, 10) .forEach((s) => console.log(` w${s.weight} ${s.text}`)); if (originalShare < 0.7) { console.warn("WARN: original share < 70%"); process.exitCode = 1; } } main().catch((err) => { console.error(err); process.exit(1); });