292 lines
9.8 KiB
JavaScript
292 lines
9.8 KiB
JavaScript
/**
|
||
* 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}“`,
|
||
);
|
||
}
|