gute
This commit is contained in:
@@ -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
|
||||
};
|
||||
@@ -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(/>/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
@@ -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(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/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();
|
||||
@@ -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(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/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
@@ -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
@@ -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(/&/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);
|
||||
});
|
||||
@@ -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
@@ -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.");
|
||||
@@ -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(/>/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();
|
||||
Reference in New Issue
Block a user