Files
2026-07-26 18:11:57 +02:00

292 lines
9.8 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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}“`,
);
}