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