/** * S04 – Stilgleiche Varianten (sparsam) + Ausgabe suggestions.ts * Input: scored-phrases.json (S03), Themes aus clean-phrases.json (S02) * * Regeln: * - ≥ 70 % Betrieb = exakte Originale * - ≤ 30 % stilgleiche Varianten * - Varianten-weight = max(1, originalWeight - 2) * - Schule: nur Originale */ import { existsSync, readFileSync, writeFileSync } from "fs"; import { dirname, join } from "path"; import { fileURLToPath } from "url"; const __dirname = dirname(fileURLToPath(import.meta.url)); const scoredPath = join(__dirname, "scored-phrases.json"); const cleanPath = join(__dirname, "clean-phrases.json"); const outTs = join(__dirname, "..", "src", "data", "suggestions.ts"); if (!existsSync(scoredPath)) { console.error("Fehlt scored-phrases.json – zuerst S03 (score-phrases.mjs) ausführen."); process.exit(1); } const scored = JSON.parse(readFileSync(scoredPath, "utf8")); const clean = existsSync(cleanPath) ? JSON.parse(readFileSync(cleanPath, "utf8")) : { themes: [], themesWeighted: [] }; /** @param {string} s */ function normKey(s) { return String(s) .replace(/\u00a0/g, " ") .replace(/\s+/g, " ") .trim() .toLowerCase() .replace(/[.…]+$/g, ""); } /** * Nur Varianten, die klar aus belegten Mustern kommen (keine neuen Themen). * `from` muss exakt (normiert) in den Originalen vorkommen. * @type {{ from: string, text: string }[]} */ const VARIANT_SPECS = [ // Beispiele aus S04 { from: "Termine vereinbart", text: "Termine telefonisch vereinbart" }, { from: "Termine vereinbart", text: "Termine mit Kunden vereinbart" }, { from: "Ablage einsortiert", text: "Ablage sortiert und archiviert" }, { from: "Rechnungen geschrieben", text: "Rechnungen per E-Mail verschickt" }, { from: "Getränke geholt", text: "Getränke für unsere Belegschaft geholt" }, // Nah an belegten Formulierungen (Wortstellung / Kürzung / Tippfehler) { from: "Telefonisch Termine vereinbart", text: "Termine telefonisch vergeben" }, { from: "Kunden telefonisch betreut", text: "Kunden per Telefon betreut" }, { from: "Kasse gezählt", text: "Kassenbuch geführt" }, { from: "Kassenbuch geführt und die Kasse gezählt auf Vollständigkeit", text: "Kassenbuch geführt und die Kasse gezählt", }, { from: "Reifen bestellt", text: "Reifen für Kunden bestellt" }, { from: "Spülmaschine ausgeräumt", text: "Spülmaschine ein- und ausgeräumt" }, { from: "Bewertungen gesammelt für autoservice.com", text: "Bewertungen für autoservice.com gesammelt", }, { from: "Lieferung angenommen und im Lager vorsortiert.", text: "Lieferung angenommen und vorsortiert", }, { from: "Mietfahrzeug verliehen an Kunden", text: "Mietfahrzeug an Kunden verliehen", }, { from: "Kundenauto nach Feuchtwangen fortgebracht.", text: "Kundenauto nach Feuchtwangen gebracht", }, { from: "Eingangsrechnung überprüft und telefonisch korrigieren lassen.", text: "Eingangsrechnung überprüft", }, { from: "Termin wünsche per E-Mail abgearbeitet", text: "Terminwünsche per E-Mail abgearbeitet", }, { from: "Auto Ersatzteil beim WM in Crailsheim abgeholt", text: "Teil beim WM in Crailsheim abgeholt", }, { from: "Cybersicherheitsherausforderungen absolviert bei „Guardey“", text: "Cybersicherheitsherausforderungen bei Guardey absolviert", }, { from: "Aufträge für die kommende Woche vorbereitet und einsortiert.", text: "Aufträge für die kommende Woche vorbereitet und einsortiert", }, { from: "Kassenbuch führen", text: "Kassenbuch kontrolliert" }, { from: "Restliche Termine für den Radwechseltag vergeben", text: "Termine für Radwechseltag vergeben", }, { from: "Rechnungen per E-Mail verschickt an Geschäftskunden.", text: "Rechnungen an Geschäftskunden per E-Mail verschickt", }, { from: "Kunden per Telefon betreut und Termine vereinbart", text: "Kunden telefonisch betreut und Termine koordiniert", }, { from: "Beim WM in Crailsheim Teile abgeholt", text: "Teile beim WM in Crailsheim abgeholt", }, ]; const phrases = Array.isArray(scored.phrases) ? scored.phrases : []; const originals = phrases.map((p) => ({ text: String(p.text).replace(/\s+/g, " ").trim(), type: p.type === "schule" ? "schule" : "betrieb", category: String(p.category || (p.type === "schule" ? "Berufsschule" : "Betrieb")), weight: Math.max(1, Number(p.weight) || 1), original: true, })); const byKey = new Map(); for (const p of originals) { byKey.set(`${p.type}|${normKey(p.text)}`, p); } /** @type {{ text: string, type: string, category: string, weight: number, original: boolean, from: string }[]} */ const variants = []; for (const spec of VARIANT_SPECS) { const fromKey = normKey(spec.from); const source = originals.find((p) => p.type === "betrieb" && normKey(p.text) === fromKey); if (!source) { console.warn(`S04: Original für Variante fehlt → übersprungen: "${spec.from}"`); continue; } const text = spec.text.replace(/\s+/g, " ").trim(); if (text.length < 6 || text.length > 160) continue; const key = `betrieb|${normKey(text)}`; if (byKey.has(key)) continue; // schon Original oder früher angelegt if (variants.some((v) => normKey(v.text) === normKey(text))) continue; const weight = Math.max(1, source.weight - 2); const item = { text, type: "betrieb", category: source.category, weight, original: false, from: source.text, }; variants.push(item); byKey.set(key, item); } const betriebOriginals = originals.filter((p) => p.type === "betrieb"); const schoolOriginals = originals.filter((p) => p.type === "schule"); // Cap: Varianten ≤ 30 % des finalen Betrieb-Pools const maxVariants = Math.floor((betriebOriginals.length * 0.3) / 0.7); const keptVariants = variants.slice(0, maxVariants); const all = [...betriebOriginals, ...keptVariants, ...schoolOriginals]; all.sort( (a, b) => (a.type === b.type ? 0 : a.type === "betrieb" ? -1 : 1) || b.weight - a.weight || a.text.localeCompare(b.text, "de"), ); const suggestions = all.map((s, i) => ({ id: i + 1, text: s.text, type: s.type, category: s.category, weight: s.weight, })); const themesWeighted = clean.themesWeighted ?? (clean.themes ?? []).map((t) => typeof t === "string" ? { text: t, count: 1 } : t, ); const themeSeen = new Set(); const allThemes = []; for (const t of themesWeighted) { const cleaned = String(typeof t === "string" ? t : t.text) .replace(/>/g, ">") .replace(/,\s*$/, "") .trim(); if (!cleaned || cleaned === "-" || cleaned.length < 3 || cleaned.length > 55) continue; if (/ferien|vorschriften/i.test(cleaned)) continue; if (/\b(abgeholt|gefahren|gebucht|auffüllen|einsortieren)\b/i.test(cleaned)) continue; const key = cleaned.toLowerCase(); if (themeSeen.has(key)) continue; themeSeen.add(key); allThemes.push(cleaned); } const betriebN = suggestions.filter((s) => s.type === "betrieb").length; const schoolN = suggestions.filter((s) => s.type === "schule").length; const originalShare = betriebN ? betriebOriginals.length / betriebN : 0; const variantShare = betriebN ? keptVariants.length / betriebN : 0; let sourceFiles = 0; for (const n of [ scored?.meta?.files, scored?.meta?.sourceFiles, scored?.meta?.hefte, clean?.meta?.files, clean?.meta?.hefte, ]) { const v = Number(n); if (Number.isFinite(v) && v > 0) { sourceFiles = v; break; } } if (!sourceFiles) { try { const rawMeta = JSON.parse(readFileSync(join(__dirname, "raw-phrases.json"), "utf8")).meta; const v = Number(rawMeta?.files || rawMeta?.hefte); if (Number.isFinite(v) && v > 0) sourceFiles = v; } catch { /* optional */ } } const stats = { total: suggestions.length, betrieb: betriebN, school: schoolN, originalsBetrieb: betriebOriginals.length, variants: keptVariants.length, originalShareBetrieb: Number(originalShare.toFixed(3)), variantShareBetrieb: Number(variantShare.toFixed(3)), sourceFiles, source: "scored-phrases.json", }; const content = `/* AUTO-GENERATED aus scored-phrases + sparsame Varianten (S04) · ${suggestions.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 (Varianten: originalWeight - 2) */ weight: number; }; export const SUGGESTIONS: Suggestion[] = ${JSON.stringify(suggestions, null, 2)}; export const WEEK_THEMES: string[] = ${JSON.stringify(allThemes, null, 2)}; export const CATEGORIES = [...new Set(SUGGESTIONS.map((s) => s.category))].sort(); export const SUGGESTION_STATS = ${JSON.stringify(stats, null, 2)} as const; `; writeFileSync(outTs, content, "utf8"); console.log(`Wrote ${suggestions.length} suggestions → ${outTs}`); console.log( `Betrieb: ${betriebN} (Originale ${betriebOriginals.length} = ${(originalShare * 100).toFixed(1)}%, Varianten ${keptVariants.length} = ${(variantShare * 100).toFixed(1)}%)`, ); console.log(`Schule: ${schoolN} (nur Originale, 0 Varianten)`); console.log(`Themes: ${allThemes.length}`); if (originalShare < 0.7 || variantShare > 0.3) { console.error("FAIL: Mengenregel verletzt (Originale ≥70 %, Varianten ≤30 %)."); process.exitCode = 1; } else { console.log("OK: Mengenregel Originale ≥70 % / Varianten ≤30 %"); } // Abnahme: Verhältnis + Stichprobe Varianten vs. Original console.log("\n--- S04 Stichprobe: Varianten ↔ Original ---"); const sample = [...keptVariants].sort(() => Math.random() - 0.5).slice(0, 20); if (sample.length < 20) { console.log(`(nur ${sample.length} Varianten im Pool – alle gelistet)`); } for (const [i, v] of sample.entries()) { console.log( `${String(i + 1).padStart(2)}. „${v.text}“ (w=${v.weight}) ← „${v.from}“`, ); }