This commit is contained in:
2026-07-26 18:11:57 +02:00
parent d408e01ab5
commit 21a3b34471
139 changed files with 32872 additions and 0 deletions
+180
View File
@@ -0,0 +1,180 @@
// src/data/holidays-bw.ts
var BW_SCHOOL_HOLIDAYS = [
// Schuljahr 2025/26
{ from: "2025-12-22", to: "2026-01-05", name: "Weihnachtsferien" },
{ from: "2026-03-30", to: "2026-04-11", name: "Osterferien" },
{ from: "2026-05-26", to: "2026-06-05", name: "Pfingstferien" },
{ from: "2026-07-30", to: "2026-09-12", name: "Sommerferien" },
{ from: "2026-10-26", to: "2026-10-30", name: "Herbstferien" },
{ from: "2026-10-31", to: "2026-10-31", name: "Reformationsfest" },
// Schuljahr 2026/27
{ from: "2026-12-23", to: "2027-01-09", name: "Weihnachtsferien" },
{ from: "2027-03-25", to: "2027-03-25", name: "Gr\xFCndonnerstag" },
{ from: "2027-03-30", to: "2027-04-03", name: "Osterferien" },
{ from: "2027-05-18", to: "2027-05-29", name: "Pfingstferien" },
{ from: "2027-07-29", to: "2027-09-11", name: "Sommerferien" },
{ from: "2027-11-02", to: "2027-11-06", name: "Herbstferien" },
// Schuljahr 2027/28
{ from: "2027-12-23", to: "2028-01-08", name: "Weihnachtsferien" }
];
var BW_PUBLIC_HOLIDAYS = {
"2026-01-01": "Neujahr",
"2026-01-06": "Heilige Drei K\xF6nige",
"2026-04-03": "Karfreitag",
"2026-04-06": "Ostermontag",
"2026-05-01": "Tag der Arbeit",
"2026-05-14": "Christi Himmelfahrt",
"2026-05-25": "Pfingstmontag",
"2026-06-04": "Fronleichnam",
"2026-10-03": "Tag der Deutschen Einheit",
"2026-11-01": "Allerheiligen",
"2026-12-25": "1. Weihnachtsfeiertag",
"2026-12-26": "2. Weihnachtsfeiertag",
"2027-01-01": "Neujahr",
"2027-01-06": "Heilige Drei K\xF6nige",
"2027-03-26": "Karfreitag",
"2027-03-29": "Ostermontag",
"2027-05-01": "Tag der Arbeit",
"2027-05-06": "Christi Himmelfahrt",
"2027-05-17": "Pfingstmontag",
"2027-05-27": "Fronleichnam",
"2027-10-03": "Tag der Deutschen Einheit",
"2027-11-01": "Allerheiligen",
"2027-12-25": "1. Weihnachtsfeiertag",
"2027-12-26": "2. Weihnachtsfeiertag"
};
var LAST_COMPLETED_DATE = "2026-04-03";
var START_WEEK_NUMBER = 79;
// src/lib/calendar.ts
var WEEKDAYS = ["Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"];
function parseISO(iso) {
const [y, m, d] = iso.split("-").map(Number);
return new Date(y, m - 1, d);
}
function toISO(date) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
function formatDE(iso) {
const [y, m, d] = iso.split("-");
return `${d}.${m}.${y}`;
}
function addDays(iso, days) {
const d = parseISO(iso);
d.setDate(d.getDate() + days);
return toISO(d);
}
function isWeekend(iso) {
const wd = parseISO(iso).getDay();
return wd === 0 || wd === 6;
}
function isInSchoolHoliday(iso) {
for (const h of BW_SCHOOL_HOLIDAYS) {
if (iso >= h.from && iso <= h.to) return h.name;
}
return void 0;
}
function getPublicHoliday(iso) {
return BW_PUBLIC_HOLIDAYS[iso];
}
function getDayType(iso) {
if (getPublicHoliday(iso)) return "feiertag";
if (isInSchoolHoliday(iso)) return "betrieb";
const wd = parseISO(iso).getDay();
if (wd === 2 || wd === 4) return "schule";
return "betrieb";
}
function getMonday(iso) {
const d = parseISO(iso);
const wd = d.getDay();
const diff = wd === 0 ? -6 : 1 - wd;
d.setDate(d.getDate() + diff);
return toISO(d);
}
function getFriday(iso) {
return addDays(getMonday(iso), 4);
}
function getWeekNumber(iso) {
const monday = getMonday(iso);
const baseMonday = getMonday(LAST_COMPLETED_DATE);
const baseWeek = START_WEEK_NUMBER - 1;
const days = (parseISO(monday).getTime() - parseISO(baseMonday).getTime()) / (24 * 60 * 60 * 1e3);
return baseWeek + Math.round(days / 7);
}
function getYearLabel(iso) {
if (iso < "2025-09-01") return "Erstes Ausbildungsjahr";
if (iso < "2026-09-01") return "Zweites Ausbildungsjahr";
return "Drittes Ausbildungsjahr";
}
function getDayInfo(iso) {
const d = parseISO(iso);
const weekday = d.getDay();
const holidayName = getPublicHoliday(iso);
const schoolHolidayName = isInSchoolHoliday(iso);
return {
date: iso,
weekday,
weekdayLabel: WEEKDAYS[weekday],
type: getDayType(iso),
holidayName,
schoolHolidayName,
weekNumber: getWeekNumber(iso),
weekMonday: getMonday(iso),
weekFriday: getFriday(iso),
yearLabel: getYearLabel(iso)
};
}
function nextWorkday(iso) {
let cur = addDays(iso, 1);
while (isWeekend(cur)) cur = addDays(cur, 1);
return cur;
}
function prevWorkday(iso) {
let cur = addDays(iso, -1);
while (isWeekend(cur)) cur = addDays(cur, -1);
return cur;
}
function firstOpenDay() {
return nextWorkday(LAST_COMPLETED_DATE);
}
function weekWorkdays(monday) {
return [0, 1, 2, 3, 4].map((i) => addDays(monday, i));
}
function weekNeedsSchool(monday) {
return weekWorkdays(monday).some((d) => getDayType(d) === "schule");
}
function feiertagActivityLine(iso) {
const info = getDayInfo(iso);
if (info.type !== "feiertag") return void 0;
return `${info.weekdayLabel} Feiertag`;
}
function typeLabel(type) {
if (type === "betrieb") return "Betrieb";
if (type === "schule") return "Berufsschule";
return "Feiertag";
}
export {
addDays,
feiertagActivityLine,
firstOpenDay,
formatDE,
getDayInfo,
getDayType,
getFriday,
getMonday,
getPublicHoliday,
getWeekNumber,
getYearLabel,
isInSchoolHoliday,
isWeekend,
nextWorkday,
parseISO,
prevWorkday,
toISO,
typeLabel,
weekNeedsSchool,
weekWorkdays
};
+291
View File
@@ -0,0 +1,291 @@
/**
* 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(/&gt;/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}`,
);
}
File diff suppressed because it is too large Load Diff
+213
View File
@@ -0,0 +1,213 @@
/**
* S02 Säubert + dedupliziert Phrasen aus raw-phrases.json → clean-phrases.json
*/
import { readFileSync, writeFileSync } from "fs";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const RAW_PATH = join(__dirname, "raw-phrases.json");
const OUT_PATH = join(__dirname, "clean-phrases.json");
function decodeEntities(s) {
return s
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)));
}
/** trim + Whitespace normalisieren + optionale Endpunkte entfernen (für Dedup-Key) */
function normalizeKey(text) {
return decodeEntities(String(text))
.replace(/\s+/g, " ")
.trim()
.replace(/\.+$/, "")
.toLowerCase();
}
/** Anzeigeform: Whitespace normalisieren, Endpunkt entfernen (Heft-Stil ohne Punkt) */
function displayText(text) {
return decodeEntities(String(text))
.replace(/\s+/g, " ")
.trim()
.replace(/\.+$/, "")
.trim();
}
const HEADER_RE =
/^(Abteilung|Ausbildungsnachweis|Für die Zeit|Name|Datum|Zweites|Erstes|Drittes|Betriebliche|Zuordnung|Thema der Woche|Berufsschule|Lfd\.?\s*Nr|Ausbilderin|Ausbilder)\b/i;
const JUNK_EXACT_RE =
/^(43180|Nico|Baumann|Ferien|Feiertag|Urlaub|Schultag|Krank|Montag|Dienstag|Mittwoch|Donnerstag|Freitag|Samstag|Sonntag|Additive,?$|\d{4,}|\d+)$/i;
function isJunk(text) {
const s = displayText(text);
if (!s || s.length < 3) return true;
if (HEADER_RE.test(s)) return true;
if (JUNK_EXACT_RE.test(s)) return true;
if (/^[.\-_\s…·•]+$/.test(s)) return true;
if (/…{2,}|\.{3,}/.test(s)) return true;
if (/Unterschrift/i.test(s)) return true;
if (/Ausbildungsnachweis|Für die Zeit|Betriebliche Tätig|Zuordnung zum Lernziel|Thema der Woche|Berufsschule|Lfd\.?\s*Nr/i.test(s))
return true;
if (/^\d{5,}/.test(s)) return true;
if (/^[\d\s./-]+$/.test(s)) return true; // reine Zahlen / Ziffernblöcke
if (/<\/?w:|xml|xmlns/i.test(s)) return true;
if (!/[a-zäöüß]/i.test(s)) return true;
return false;
}
function isFerienActivity(s) {
return /^ferien\b/i.test(s) || /^ferien$/i.test(s);
}
/**
* @param {Map<string, { text: string, count: number, sources?: Set<string> }>} map
* @param {string} raw
* @param {string | null} sourceFile
* @param {{ trackSources?: boolean, minLen?: number, maxLen?: number }} opts
*/
function addPhrase(map, raw, sourceFile, opts = {}) {
const { trackSources = false, minLen = 4, maxLen = 180 } = opts;
if (isJunk(raw)) return;
const text = displayText(raw);
if (!text || text.length < minLen || text.length > maxLen) return;
if (trackSources && isFerienActivity(text)) return;
const key = normalizeKey(text);
if (!key) return;
const prev = map.get(key);
if (prev) {
prev.count += 1;
// kürzere / punktlose Form bevorzugen
if (text.length < prev.text.length || (!/\.$/.test(raw) && /\.$/.test(prev.text))) {
prev.text = text;
}
if (trackSources && sourceFile) prev.sources.add(sourceFile);
} else {
map.set(key, {
text,
count: 1,
...(trackSources ? { sources: new Set(sourceFile ? [sourceFile] : []) } : {}),
});
}
}
function toSortedList(map, { withSources = false } = {}) {
return [...map.values()]
.map((entry) => {
if (withSources) {
return {
text: entry.text,
count: entry.count,
sources: [...entry.sources].sort((a, b) => a.localeCompare(b, "de")),
};
}
return { text: entry.text, count: entry.count };
})
.sort((a, b) => b.count - a.count || a.text.localeCompare(b.text, "de"));
}
function main() {
let raw;
try {
raw = JSON.parse(readFileSync(RAW_PATH, "utf8"));
} catch (err) {
console.error(
"raw-phrases.json fehlt oder ist ungültig. Zuerst S01 (extract-phrases.mjs) ausführen.",
);
console.error(err.message);
process.exit(1);
}
const hefte = raw.hefte ?? [];
if (!hefte.length) {
console.error("raw-phrases.json enthält keine Hefte.");
process.exit(1);
}
const activities = new Map();
const themes = new Map();
const school = new Map();
for (const heft of hefte) {
const source = heft.sourceFile ?? null;
// Pro Heft einmal pro normalisierter Phrase zählen (Tabellen-Duplikate)
const seenAct = new Set();
for (const a of heft.activities ?? []) {
const key = normalizeKey(a);
if (!key || seenAct.has(key)) continue;
seenAct.add(key);
addPhrase(activities, a, source, {
trackSources: true,
minLen: 6,
maxLen: 160,
});
}
const seenTheme = new Set();
for (const t of heft.weekThemes ?? []) {
const key = normalizeKey(t);
if (!key || seenTheme.has(key)) continue;
seenTheme.add(key);
if (/ferien/i.test(displayText(t))) continue;
addPhrase(themes, t, null, { minLen: 3, maxLen: 80 });
}
const seenSchool = new Set();
for (const s of heft.schoolTopics ?? []) {
const key = normalizeKey(s);
if (!key || seenSchool.has(key)) continue;
seenSchool.add(key);
// Ferien gehört ins Berufsschule-Feld der Woche, nicht als Suggestion-Phrase
if (/ferien/i.test(displayText(s))) continue;
addPhrase(school, s, null, { minLen: 4, maxLen: 160 });
}
}
const actList = toSortedList(activities, { withSources: true }).filter(
(a) =>
!/^\d/.test(a.text) &&
!/Lfd/i.test(a.text) &&
/[a-zäöüß]{3,}/i.test(a.text),
);
const themeList = toSortedList(themes).filter(
(t) => t.text !== "-" && !/^[.\-]+$/.test(t.text),
);
const schoolList = toSortedList(school).filter(
(s) => !/^krank$/i.test(s.text) && !/stillarbeit$/i.test(s.text),
);
const clean = {
activities: actList,
themes: themeList,
school: schoolList,
meta: {
source: "raw-phrases.json",
hefte: hefte.length,
activities: actList.length,
themes: themeList.length,
school: schoolList.length,
generatedAt: new Date().toISOString(),
},
};
writeFileSync(OUT_PATH, JSON.stringify(clean, null, 2), "utf8");
console.log(
`Clean: ${hefte.length} Hefte → ${actList.length} Activities, ${themeList.length} Themes, ${schoolList.length} School`,
);
console.log(
"Top Activities:",
actList
.slice(0, 12)
.map((a) => `${a.text} (${a.count})`)
.join(" · "),
);
}
main();
+336
View File
@@ -0,0 +1,336 @@
/**
* S01 Extrahiert Rohtext + strukturierte Phrasen aus allen Berichtsheft-.docx.
* Ausgabe: extracted-all.txt, raw-phrases.json (+ Aggregat für spätere Schritte).
*/
import { readdirSync, readFileSync, writeFileSync, statSync } from "fs";
import { dirname, join, resolve, basename } from "path";
import { fileURLToPath } from "url";
import PizZip from "pizzip";
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, "..", "..");
const YEAR_DIRS = [
join(ROOT, "1. Ausbildungsjahr"),
join(ROOT, "2. Ausbildungsjahr"),
];
function listDocx(dir) {
try {
return readdirSync(dir)
.filter((f) => f.toLowerCase().endsWith(".docx") && !f.startsWith("~$"))
.map((f) => join(dir, f))
.filter((p) => statSync(p).isFile());
} catch {
return [];
}
}
function docxToLines(path) {
const zip = new PizZip(readFileSync(path));
const file = zip.file("word/document.xml");
if (!file) return [];
const xml = file.asText();
return xml
.split(/<\/w:p>/)
.map((p) =>
[...p.matchAll(/<w:t[^>]*>([^<]*)<\/w:t>/g)]
.map((m) => m[1])
.join("")
.replace(/\s+/g, " ")
.trim(),
)
.filter(Boolean);
}
function dedupeConsecutive(lines) {
/** Content is duplicated (two table columns) keep first occurrence. */
const out = [];
const seenInDoc = new Set();
for (const line of lines) {
const key = line.toLowerCase();
if (seenInDoc.has(key)) continue;
seenInDoc.add(key);
out.push(line);
}
return out;
}
function sliceBetween(lines, startRe, endRe) {
const start = lines.findIndex((l) => startRe.test(l));
if (start < 0) return [];
const rest = lines.slice(start + 1);
const end = rest.findIndex((l) => endRe.test(l));
return end < 0 ? rest : rest.slice(0, end);
}
function decodeEntities(s) {
return s
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)));
}
function stripLeadingJunk(s) {
return decodeEntities(s)
.replace(/^[\d\s]+/, "")
.replace(/^[\s\-•·]+/, "")
.replace(/^\d{4,}/, "")
.replace(/\s+/g, " ")
.trim()
.replace(/[,\s]+$/, "")
.trim();
}
function isHeaderNoise(s) {
return /^(Abteilung|Ausbildungsnachweis|Für die Zeit|Name|Datum|Zweites|Erstes|Drittes|Betriebliche|Zuordnung|Thema der Woche|Berufsschule|Lfd\.?\s*Nr|Ausbilderin|Ausbilder)/i.test(
s,
);
}
const JUNK_RE =
/^(43180|Nico|Baumann|Ferien|Feiertag|Urlaub|Schultag|Krank|Montag|Dienstag|Mittwoch|Donnerstag|Freitag|Samstag|Sonntag|………………|…………|\.+|-+|_+|Additive,?$|\d{4,}|\d+$)/i;
function isJunk(s) {
if (!s || s.length < 3) return true;
if (JUNK_RE.test(s)) return true;
if (/^[.\-_\s…]+$/.test(s)) return true;
if (/Unterschrift/i.test(s)) return true;
if (
/Ausbildungsnachweis|Für die Zeit|Betriebliche Tätig|Zuordnung|Thema der Woche|Berufsschule|Lfd\.?\s*Nr/i.test(
s,
)
)
return true;
if (/^\d{5,}/.test(s)) return true;
if (/Zwischenprüfung\s+Freitag/i.test(s)) return true;
if (!/[a-zäöüß]/i.test(s)) return true;
return false;
}
function cleanPhrase(text) {
const t = stripLeadingJunk(text);
if (isJunk(t) || isHeaderNoise(t)) return null;
if (t.length < 3 || t.length > 180) return null;
return t;
}
function isFrameworkRef(s) {
return (
/^Lfd\.?\s*Nr/i.test(s) ||
/^\d+(\.\d+)+$/.test(s) ||
/Ausbildungsrahmenplan/i.test(s) ||
/^Zuordnung der Tätigkeit/i.test(s)
);
}
function isPlausibleTheme(s) {
if (!s || s.length < 3 || s.length > 55) return false;
if (/[.]{2,}/.test(s)) return false;
if (/\b(abgeholt|gefahren|gebucht|auffüllen|einsortieren|anbieten|festhalten)\b/i.test(s))
return false;
if (/^(bei |zum |was |welche |wenn |mehr |immer )/i.test(s)) return false;
return true;
}
function parseSections(lines) {
const unique = dedupeConsecutive(lines);
const betriebIdx = unique.findIndex((l) => /^Betriebliche Tätigkeiten/i.test(l));
let activityLines = [];
if (betriebIdx > 0) {
let start = 0;
for (let i = 0; i < betriebIdx; i++) {
if (/^Name\b/i.test(unique[i]) || /^Datum\b/i.test(unique[i])) start = i + 1;
}
activityLines = unique.slice(start, betriebIdx);
}
// Rahmenplan-Refs zwischen „Betriebliche Tätigkeiten“ und „Thema der Woche“
const frameworkRaw = sliceBetween(
unique,
/^Betriebliche Tätigkeiten/i,
/^Thema der Woche/i,
);
const themeAfter = sliceBetween(
unique,
/^Thema der Woche/i,
/^(Zuordnung zum Lernziel|Berufsschule)/i,
);
const schoolLines = sliceBetween(
unique,
/^Berufsschule/i,
/^(Datum und Unterschrift|\.{3,}|…{2,})/i,
).filter((l) => !/^(Datum und Unterschrift|\.{3,}|…)/i.test(l));
return {
activityLines,
frameworkRaw,
themeLines: themeAfter,
schoolLines,
};
}
function addCount(map, text) {
const t = cleanPhrase(text);
if (!t) return;
const key = t.toLowerCase();
const prev = map.get(key);
if (prev) prev.count += 1;
else map.set(key, { text: t, count: 1 });
}
function toSortedList(map, { minLen = 4, maxLen = 180 } = {}) {
return [...map.values()]
.filter((x) => x.text.length >= minLen && x.text.length <= maxLen)
.sort((a, b) => b.count - a.count || a.text.localeCompare(b.text, "de"));
}
function structureHeft(path, lines) {
const { activityLines, frameworkRaw, themeLines, schoolLines } = parseSections(lines);
const activities = [];
for (const a of activityLines) {
const t = cleanPhrase(a);
if (t) activities.push(t);
}
const frameworkRefs = [];
for (const f of frameworkRaw) {
const raw = decodeEntities(f).replace(/\s+/g, " ").trim();
if (!raw) continue;
if (isFrameworkRef(raw) || /^Lfd/i.test(raw)) {
frameworkRefs.push(raw);
continue;
}
// kurze Stichworte vor „Thema der Woche“ oft Rahmenplan-/Themenreste behalten zum Filtern
const cleaned = stripLeadingJunk(raw);
if (
cleaned &&
cleaned.length <= 40 &&
!isJunk(cleaned) &&
!/^[.\-…]+$/.test(cleaned)
) {
frameworkRefs.push(cleaned);
}
}
const weekThemes = [];
for (const t of themeLines) {
const cleaned = stripLeadingJunk(t);
if (!cleaned || cleaned === "-") continue;
if (/ferien/i.test(cleaned)) continue;
if (!isPlausibleTheme(cleaned)) continue;
if (isJunk(cleaned) || isHeaderNoise(cleaned)) continue;
weekThemes.push(cleaned);
}
const schoolTopics = [];
for (const s of schoolLines) {
if (/ferien/i.test(s)) continue;
const t = cleanPhrase(s);
if (t) schoolTopics.push(t);
}
return {
sourceFile: basename(path),
activities,
weekThemes,
schoolTopics,
frameworkRefs,
};
}
async function main() {
const files = YEAR_DIRS.flatMap(listDocx).sort((a, b) =>
basename(a).localeCompare(basename(b), "de"),
);
if (!files.length) {
console.error("Keine .docx in 1./2. Ausbildungsjahr gefunden.");
process.exit(1);
}
const activities = new Map();
const themes = new Map();
const school = new Map();
const dump = [];
const rawPhrases = [];
for (const path of files) {
const lines = docxToLines(path);
const name = basename(path);
dump.push(`===== ${name} =====`, "", ...lines, "", "");
const structured = structureHeft(path, lines);
rawPhrases.push(structured);
for (const a of structured.activities) addCount(activities, a);
for (const t of structured.weekThemes) addCount(themes, t);
for (const s of structured.schoolTopics) addCount(school, s);
}
const actList = toSortedList(activities, { minLen: 6, maxLen: 160 }).filter(
(a) =>
!/^\d/.test(a.text) &&
!/Lfd/i.test(a.text) &&
/[a-zäöüß]{3,}/i.test(a.text),
);
const themeList = toSortedList(themes, { minLen: 3, maxLen: 80 }).filter(
(t) => !/^[.\-]+$/.test(t.text) && t.text !== "-",
);
const schoolList = toSortedList(school, { minLen: 4, maxLen: 160 }).filter(
(s) => !/^krank$/i.test(s.text) && !/stillarbeit$/i.test(s.text),
);
const clean = {
activities: actList,
themes: themeList.map((t) => t.text),
themesWeighted: themeList,
school: schoolList.map((s) => s.text),
schoolWeighted: schoolList,
meta: {
files: files.length,
activities: actList.length,
themes: themeList.length,
school: schoolList.length,
generatedAt: new Date().toISOString(),
},
};
const rawOut = {
meta: {
files: files.length,
hefte: rawPhrases.length,
generatedAt: new Date().toISOString(),
},
hefte: rawPhrases,
};
writeFileSync(join(__dirname, "extracted-all.txt"), dump.join("\n"), "utf8");
writeFileSync(join(__dirname, "raw-phrases.json"), JSON.stringify(rawOut, null, 2), "utf8");
// Aggregat nur als Zwischenstand; saubere Form kommt aus S02 (clean-phrases.mjs)
writeFileSync(join(__dirname, "parsed-phrases.json"), JSON.stringify(clean, null, 2), "utf8");
const withActs = rawPhrases.filter((h) => h.activities.length > 0).length;
const emptyActs = rawPhrases.filter((h) => h.activities.length === 0);
console.log(
`Parsed ${files.length} docx → raw-phrases.json (${withActs} mit Activities, ${emptyActs.length} ohne)`,
);
console.log(
`Aggregat: ${actList.length} Tätigkeiten, ${themeList.length} Themen, ${schoolList.length} Schule`,
);
if (emptyActs.length) {
console.log(
"Ohne Activities:",
emptyActs.map((h) => h.sourceFile).join(" · "),
);
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
File diff suppressed because it is too large Load Diff
+15
View File
@@ -0,0 +1,15 @@
/**
* @deprecated Nutze `npm run generate:suggestions`
* (extract-phrases.mjs → clean-phrases.mjs → build-real-suggestions.mjs).
*/
import { spawnSync } from "child_process";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
const dir = dirname(fileURLToPath(import.meta.url));
const steps = ["extract-phrases.mjs", "clean-phrases.mjs", "build-real-suggestions.mjs"];
for (const step of steps) {
const r = spawnSync(process.execPath, [join(dir, step)], { stdio: "inherit" });
if (r.status) process.exit(r.status ?? 1);
}
process.exit(0);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+433
View File
@@ -0,0 +1,433 @@
/**
* 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(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/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);
});
+285
View File
@@ -0,0 +1,285 @@
/**
* S03 Kategorisieren + Gewichten
* Liest clean-phrases.json → schreibt scored-phrases.json
* (jede Phrase: type, category, weight).
*/
import { readFileSync, writeFileSync, existsSync } from "fs";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const cleanPath = join(__dirname, "clean-phrases.json");
const dumpPath = join(__dirname, "extracted-all.txt");
const outPath = join(__dirname, "scored-phrases.json");
const raw = JSON.parse(readFileSync(cleanPath, "utf8"));
/** @param {string} text */
function categorizeBetrieb(text) {
const t = text.toLowerCase();
if (
/rechnung|gutschrift|zahlungs|mahnung|avis|skonto|betrag|\bbar\b|ec-|kassenladenabschluss|eingangsrechnung|lieferschein/.test(
t,
)
)
return "Rechnungswesen";
if (
/termin|kunde|kundin|telefon|bewertung|mobilita|mobilitäts|angebot|autoservice|digitale kundenkarte/.test(
t,
)
)
return "Kunden";
if (
/reifen|lager|lieferung|bestell|ware|artikel|\bwm\b|retour|teile|batterien|additive|warenrückgabe|lieferscheine/.test(
t,
)
)
return "Lager";
if (/kasse|kassenbuch/.test(t)) return "Kasse";
if (
/fahrzeug|abmeld|zulassung|versicherung|unfall|schaden|getriebe|mietfahrzeug|tanken|ausgeputzt|rausgeputzt/.test(
t,
)
)
return "Fahrzeuge";
if (
/gefahren|gebracht|abgeholt|feuchtwangen|crailsheim|rothenburg|getränke|getranke|bosch|guardey|karlsruhe/.test(
t,
)
)
return "Fahrten";
if (
/ablage|spül|auftr[aä]ge?|büro|cyber|kalender|archiv|papier|wartebereich|betriebsurlaub|organisiert|einsortiert/.test(
t,
)
)
return "Organisation";
return "Betrieb";
}
/** @param {string} text */
function categorizeSchool(text) {
const t = text.toLowerCase();
if (
/bwl|kalkulation|beschaffung|lagerkenn|polypol|bruttoinlands|bestell|markt|preis|abc-analyse|abc-kunden|wettbewerbs|absatzpolit|bip\b|wohlstands|werbung|personalauswahl/.test(
t,
)
)
return "BWL";
if (
/buchhaltung|buchung|rechnung bestandteil|umsatzsteuer|sozialabgaben|inventar|bilanz|guv|konten/.test(
t,
)
)
return "Buchhaltung";
if (
/englisch|introducing|listening|dialogues|briefe im englischen|telefonate|mediation|enquiry|hörverstehen/.test(
t,
)
)
return "Englisch";
if (
/deutsch|das-\/dass|rechtschreib|stellenanzeige|leserbrief|argumentation|protokoll|ausdrucksvermögen|deutschbuch/.test(
t,
)
)
return "Deutsch";
if (
/excel|word|textverarbeitung|formatvorlage|diagramm|ergonomie|seriendruck|powerpoint|summen-funktionen|min-funktionen/.test(
t,
)
)
return "IT";
if (
/geschichte|migration|medien|politik|tarif|arbeitsschutz|demograf|bundestag|mauer|religion|demokratie|volksentscheid|integration|assimilation|homeoffice/.test(
t,
)
)
return "Gesellschaft";
if (
/projekt|präsentation|kommunikation|meeting|checkliste|e-mail|büromanagement|\bbfk\b|zwischenprüfung|zeitmanagement|zeugnis/.test(
t,
)
)
return "BFK";
return "Berufsschule";
}
/**
* Optional: Phrasen, die in beiden Ausbildungsjahren vorkommen → weight +1..+2
* Quelle: extracted-all.txt (Jahr-Marker „Erstes/Zweites Ausbildungsjahr“).
* @returns {Map<string, Set<number>>}
*/
function buildYearPresence() {
/** @type {Map<string, Set<number>>} */
const yearsByKey = new Map();
if (!existsSync(dumpPath)) return yearsByKey;
const dump = readFileSync(dumpPath, "utf8");
const blocks = dump.split(/^===== .+ =====$/m).slice(1);
for (const block of blocks) {
let year = 0;
if (/Erstes Ausbildungsjahr/i.test(block)) year = 1;
else if (/Zweites Ausbildungsjahr/i.test(block)) year = 2;
if (!year) continue;
const lines = block.split(/\r?\n/);
for (const line of lines) {
const key = line.replace(/\s+/g, " ").trim().toLowerCase();
if (key.length < 4) continue;
let set = yearsByKey.get(key);
if (!set) {
set = new Set();
yearsByKey.set(key, set);
}
set.add(year);
}
}
return yearsByKey;
}
/**
* @param {number} count
* @param {Set<number>|undefined} years
*/
function computeWeight(count, years) {
let weight = Math.max(1, count || 1);
if (years && years.size >= 2) {
// leichte Anhebung: häufige Phrasen +2, sonst +1
weight += weight >= 3 ? 2 : 1;
}
return weight;
}
/** @param {unknown} item */
function asEntry(item) {
if (typeof item === "string") return { text: item.trim(), count: 1 };
if (item && typeof item === "object" && "text" in item) {
const text = String(/** @type {{text: unknown}} */ (item).text).trim();
const count =
"count" in item && typeof /** @type {{count: unknown}} */ (item).count === "number"
? /** @type {{count: number}} */ (item).count
: 1;
return { text, count };
}
return null;
}
const yearPresence = buildYearPresence();
/** @type {{ text: string, type: 'betrieb'|'schule', category: string, weight: number, count: number, years: number[] }[]} */
const scored = [];
const seen = new Set();
/**
* @param {string} text
* @param {'betrieb'|'schule'} type
* @param {string} category
* @param {number} count
*/
function pushPhrase(text, type, category, count) {
const t = text.replace(/\s+/g, " ").trim();
if (!t) return;
const key = `${type}::${t.toLowerCase()}`;
if (seen.has(key)) return;
seen.add(key);
const years = yearPresence.get(t.toLowerCase());
const yearList = years ? [...years].sort() : [];
scored.push({
text: t,
type,
category,
weight: computeWeight(count, years),
count: Math.max(1, count || 1),
years: yearList,
});
}
const activities = raw.activities ?? [];
for (const a of activities) {
const e = asEntry(a);
if (!e) continue;
pushPhrase(e.text, "betrieb", categorizeBetrieb(e.text), e.count);
}
const schoolSource =
Array.isArray(raw.schoolWeighted) && raw.schoolWeighted.length
? raw.schoolWeighted
: (raw.school ?? []);
for (const s of schoolSource) {
const e = asEntry(s);
if (!e) continue;
pushPhrase(e.text, "schule", categorizeSchool(e.text), e.count);
}
scored.sort(
(a, b) =>
(a.type === b.type ? 0 : a.type === "betrieb" ? -1 : 1) ||
b.weight - a.weight ||
a.text.localeCompare(b.text, "de"),
);
const out = {
phrases: scored.map(({ text, type, category, weight }) => ({
text,
type,
category,
weight,
})),
meta: {
source: "clean-phrases.json",
total: scored.length,
betrieb: scored.filter((p) => p.type === "betrieb").length,
schule: scored.filter((p) => p.type === "schule").length,
multiYearBoosted: scored.filter((p) => p.years.length >= 2).length,
yearPresenceFrom: existsSync(dumpPath) ? "extracted-all.txt" : null,
generatedAt: new Date().toISOString(),
// Debug-Hilfen für Abnahme (nicht Teil des Datenmodells)
topBetrieb: scored
.filter((p) => p.type === "betrieb")
.slice(0, 10)
.map((p) => `${p.text}×${p.weight} [${p.category}]`),
topSchule: scored
.filter((p) => p.type === "schule")
.slice(0, 10)
.map((p) => `${p.text}×${p.weight} [${p.category}]`),
categoryCounts: scored.reduce((acc, p) => {
acc[p.category] = (acc[p.category] || 0) + 1;
return acc;
}, /** @type {Record<string, number>} */ ({})),
},
};
writeFileSync(outPath, JSON.stringify(out, null, 2), "utf8");
// Abnahme-Checks
const missing = out.phrases.filter((p) => !p.type || !p.category || !p.weight);
const schoolAsBetrieb = out.phrases.filter(
(p) =>
p.type === "betrieb" &&
/bwl|englisch|polypol|introducing|klassenarbeit|berufsschule|excel klassen|hörverstehen|umsatzsteuer|das-\/dass/i.test(
p.text,
),
);
console.log(
`Wrote ${out.phrases.length} scored phrases (betrieb ${out.meta.betrieb}, schule ${out.meta.schule}) → ${outPath}`,
);
console.log(`Multi-year boost: ${out.meta.multiYearBoosted}`);
console.log("Top Betrieb:", out.meta.topBetrieb.slice(0, 6).join(" · "));
console.log("Top Schule:", out.meta.topSchule.slice(0, 6).join(" · "));
if (missing.length) {
console.error(`FAIL: ${missing.length} Phrasen ohne type/category/weight`);
process.exit(1);
}
if (schoolAsBetrieb.length) {
console.warn(
`WARN Stichprobe: ${schoolAsBetrieb.length} mögliche Schule-als-Betrieb:`,
schoolAsBetrieb.slice(0, 5).map((p) => p.text),
);
} else {
console.log("OK: Schule-Stichworte nicht als betrieb gelabelt");
}
console.log("OK: jede Phrase hat type, category, weight");
File diff suppressed because it is too large Load Diff
+137
View File
@@ -0,0 +1,137 @@
/**
* Smoke-Checks für Teilaufgabe 04 (Kalenderregeln).
* Bündelt die echten Module via esbuild und prüft die Abnahmekriterien.
*/
import { execSync } from "node:child_process";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const webRoot = join(__dirname, "..");
const outDir = join(__dirname, ".tmp-verify");
const entry = join(outDir, "entry.mjs");
const bundle = join(outDir, "bundle.mjs");
function assert(cond, msg) {
if (!cond) throw new Error(`FAIL: ${msg}`);
}
mkdirSync(outDir, { recursive: true });
writeFileSync(
entry,
`
export {
feiertagActivityLine,
firstOpenDay,
getDayType,
getPublicHoliday,
getWeekNumber,
isInSchoolHoliday,
isWeekend,
weekNeedsSchool,
weekWorkdays,
} from "../../src/lib/calendar.ts";
export { createEmptyWeek } from "../../src/lib/storage.ts";
export { pickRandomSuggestions } from "../../src/lib/suggest.ts";
export { buildActivityLines, buildSchoolTopics } from "../../src/lib/weekExport.ts";
`,
);
execSync(
`npx esbuild "${entry}" --bundle --platform=node --format=esm --outfile="${bundle}"`,
{ cwd: webRoot, stdio: "inherit" },
);
const m = await import(pathToFileURL(bundle).href);
// 1) Normale Schulwoche nach Osterferien
const schoolWeek = "2026-04-13";
assert(
JSON.stringify(m.weekWorkdays(schoolWeek).map(m.getDayType)) ===
JSON.stringify(["betrieb", "schule", "betrieb", "schule", "betrieb"]),
"Schulwoche 13.04.2026: Di/Do = Schule",
);
assert(m.weekNeedsSchool(schoolWeek) === true, "Schulwoche braucht Schule");
// 2) Ferien → Betrieb auch Di/Do
assert(m.isInSchoolHoliday("2026-04-07") === "Osterferien", "Di in Osterferien");
assert(m.getDayType("2026-04-07") === "betrieb", "Di Ferien = Betrieb");
assert(m.getDayType("2026-04-09") === "betrieb", "Do Ferien = Betrieb");
// 3) Wochenende überspringen
assert(m.isWeekend("2026-04-11") && m.isWeekend("2026-04-12"), "Sa/So");
assert(
!m.weekWorkdays("2026-04-06").includes("2026-04-11"),
"Sa nicht in Workdays",
);
// 4) Abnahme Woche 79
const w79 = "2026-04-06";
assert(m.getWeekNumber(w79) === 79, "Wochennummer 79");
assert(m.firstOpenDay() === "2026-04-06", "Starttag nach Heft 78");
assert(m.getDayType(w79) === "feiertag", "Ostermontag Feiertag");
assert(m.getPublicHoliday(w79) === "Ostermontag", "Ostermontag Name");
assert(
m.weekWorkdays(w79).slice(1).every((d) => m.getDayType(d) === "betrieb"),
"Rest Woche 79 = Betrieb",
);
assert(m.weekNeedsSchool(w79) === false, "Woche 79 keine Schule");
assert(
JSON.stringify(m.createEmptyWeek(w79).schoolTopics) ===
JSON.stringify(["Ferien"]),
"Berufsschule-Feld = Ferien",
);
assert(m.feiertagActivityLine(w79) === "Montag Feiertag", "Feiertag-Zeile");
// 5) Export-Zeilen
const week = m.createEmptyWeek(w79);
week.activities = ["Reifenlager inventarisiert"];
assert(
JSON.stringify(m.buildActivityLines(week)) ===
JSON.stringify(["Reifenlager inventarisiert", "Montag Feiertag"]),
"Export enthält Montag Feiertag",
);
assert(
JSON.stringify(m.buildSchoolTopics(week)) === JSON.stringify(["Ferien"]),
"Export Schule = Ferien",
);
// 6) Vorschlags-Pool
const ferienPool = m.pickRandomSuggestions(w79, 8, [], []);
assert(
ferienPool.every((s) => s.type === "betrieb"),
"Ferienwoche: nur Betrieb-Vorschläge",
);
const schulPool = m.pickRandomSuggestions(schoolWeek, 8, [], []);
assert(
schulPool.some((s) => s.type === "schule") &&
schulPool.some((s) => s.type === "betrieb"),
"Schulwoche: Betrieb + Schule im Pool",
);
// 7) Wochennummern relativ zu 78
assert(m.getWeekNumber("2026-03-30") === 78, "Woche 78");
assert(m.getWeekNumber("2026-04-13") === 80, "Woche 80");
// 8) Pflicht-Feiertage 2026
for (const iso of [
"2026-01-01",
"2026-01-06",
"2026-04-03",
"2026-04-06",
"2026-05-01",
"2026-05-14",
"2026-05-25",
"2026-06-04",
"2026-10-03",
"2026-11-01",
"2026-12-25",
"2026-12-26",
]) {
assert(Boolean(m.getPublicHoliday(iso)), `Feiertag ${iso}`);
assert(m.getDayType(iso) === "feiertag", `Typ Feiertag ${iso}`);
}
rmSync(outDir, { recursive: true, force: true });
console.log("OK alle Kalender-Smoke-Checks bestanden.");
+153
View File
@@ -0,0 +1,153 @@
/**
* 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(/&gt;/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();