/** * Qualitätstor für Suggestions (S06). * Failt bei Müll, ungültigem type/weight, Duplikaten oder zu wenig Originalen. * * Usage: npm run check:suggestions */ import { readFileSync } from "fs"; import { dirname, join } from "path"; import { fileURLToPath } from "url"; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = join(__dirname, ".."); const SUGGESTIONS_PATH = join(ROOT, "src", "data", "suggestions.ts"); const CLEAN_PATH = join(__dirname, "clean-phrases.json"); const MIN_ORIGINAL_SHARE = 0.7; const VALID_TYPES = new Set(["betrieb", "schule"]); /** Müll: Header/Signatur/Nummern-Artefakte – nicht echte Sätze mit „zur Unterschrift …“. */ const JUNK_RE = /Datum und Unterschrift|\bLfd\.?\s*Nr\b|……|…………|\.{4,}|\d{5,}|Ausbildungsnachweis|Für die Zeit|^Unterschrift\b|^Abteilung\b|Zuordnung zum Lernziel/i; /** * @param {string} src * @returns {{ suggestions: any[], themes: string[] }} */ function parseSuggestionsTs(src) { const sugKey = "export const SUGGESTIONS: Suggestion[] = "; const themeKey = "export const WEEK_THEMES: string[] = "; const sugStart = src.indexOf(sugKey); if (sugStart < 0) throw new Error("SUGGESTIONS-Export nicht gefunden"); const afterSug = sugStart + sugKey.length; const themeStart = src.indexOf(themeKey, afterSug); if (themeStart < 0) throw new Error("WEEK_THEMES-Export nicht gefunden"); const sugJson = src.slice(afterSug, themeStart).trim().replace(/;$/, ""); const afterTheme = themeStart + themeKey.length; const nextExport = src.indexOf("\nexport const ", afterTheme); if (nextExport < 0) throw new Error("Ende von WEEK_THEMES nicht gefunden"); const themeJson = src.slice(afterTheme, nextExport).trim().replace(/;$/, ""); return { suggestions: JSON.parse(sugJson), themes: JSON.parse(themeJson), }; } function fail(msg) { console.error(`FAIL: ${msg}`); } function main() { const errors = []; const { suggestions, themes } = parseSuggestionsTs( readFileSync(SUGGESTIONS_PATH, "utf8"), ); const clean = JSON.parse(readFileSync(CLEAN_PATH, "utf8")); const originalTexts = new Set( (clean.activities ?? []).map((a) => (typeof a === "string" ? a : a.text).toLowerCase().replace(/\s+/g, " ").trim(), ), ); const themeText = (t) => (typeof t === "string" ? t : t?.text); const realThemes = new Set( [...(clean.themes ?? []), ...(clean.themesWeighted ?? [])] .map(themeText) .map((t) => String(t ?? "") .replace(/>/g, ">") .replace(/,\s*$/, "") .trim() .toLowerCase(), ) .filter(Boolean), ); if (!Array.isArray(suggestions) || suggestions.length === 0) { errors.push("SUGGESTIONS leer oder ungültig"); } const seen = new Map(); let junkCount = 0; let badType = 0; let badWeight = 0; let dupCount = 0; for (const s of suggestions) { const text = String(s?.text ?? "").replace(/\s+/g, " ").trim(); if (JUNK_RE.test(text)) { junkCount++; if (junkCount <= 8) fail(`Müll-Regex: id=${s.id} "${text}"`); } if (!VALID_TYPES.has(s?.type)) { badType++; if (badType <= 8) fail(`type fehlt/ungültig: id=${s?.id} type=${s?.type}`); } if (!(Number(s?.weight) >= 1)) { badWeight++; if (badWeight <= 8) fail(`weight < 1: id=${s?.id} weight=${s?.weight}`); } const key = text.toLowerCase(); if (seen.has(key)) { dupCount++; if (dupCount <= 8) fail(`Duplikat: "${text}" (ids ${seen.get(key)}, ${s.id})`); } else { seen.set(key, s.id); } } if (junkCount) errors.push(`${junkCount} Phrase(n) matchen Müll-Regex`); if (badType) errors.push(`${badType} Phrase(n) ohne gültigen type`); if (badWeight) errors.push(`${badWeight} Phrase(n) mit weight < 1`); if (dupCount) errors.push(`${dupCount} case-insensitive Duplikat(e)`); const betrieb = suggestions.filter((s) => s.type === "betrieb"); const schule = suggestions.filter((s) => s.type === "schule"); const exactBetrieb = betrieb.filter((s) => originalTexts.has(String(s.text).toLowerCase().replace(/\s+/g, " ").trim()), ); const originalShare = betrieb.length ? exactBetrieb.length / betrieb.length : 0; if (originalShare < MIN_ORIGINAL_SHARE) { errors.push( `Original-Anteil Betrieb ${(originalShare * 100).toFixed(1)}% < ${MIN_ORIGINAL_SHARE * 100}% (${exactBetrieb.length}/${betrieb.length})`, ); } const fakeThemes = themes.filter((t) => !realThemes.has(String(t).trim().toLowerCase())); if (fakeThemes.length) { errors.push( `${fakeThemes.length} WEEK_THEMES nicht in clean-phrases: ${fakeThemes.slice(0, 5).join(" · ")}`, ); } const top10 = [...betrieb] .sort((a, b) => b.weight - a.weight || a.text.localeCompare(b.text, "de")) .slice(0, 10); console.log("=== Suggestions Qualitätstor ==="); console.log(`Gesamt: ${suggestions.length} (Betrieb ${betrieb.length}, Schule ${schule.length})`); console.log( `Original-Anteil Betrieb: ${(originalShare * 100).toFixed(1)}% (${exactBetrieb.length}/${betrieb.length})`, ); console.log(`WEEK_THEMES: ${themes.length} (echte Themen)`); console.log("Top-10 Betrieb:"); for (const s of top10) console.log(` ${s.weight}× ${s.text}`); if (errors.length) { console.error("\nQualitätstor FEHLGESCHLAGEN:"); for (const e of errors) console.error(` - ${e}`); process.exit(1); } console.log("\nQualitätstor OK"); } main();