diff --git a/SKILL.md b/SKILL.md index bfed748..840260b 100644 --- a/SKILL.md +++ b/SKILL.md @@ -136,7 +136,7 @@ Prüfe vor Übergabe an den Kunden: **Content-Skill**: Alle 10 Hook-Typen mit kundenspezifischen Beispielen, Säulen summieren auf 100%, keine Engagement-Bait-Phrasen. -**Scoring**: Jede Kategorie mit Begründung, gewichtete Summe korrekt berechnet, ≥3 Hebel identifiziert. +**Scoring**: Jede Kategorie mit Begründung, gewichtete Summe korrekt berechnet, ≥3 Hebel identifiziert. Keine Kategorie geschätzt, für die Daten fehlten; nicht erhobene Kategorien sind gekennzeichnet und der Gesamtscore ist dann eine Spanne (`references/SCORING.md`). **Report**: DOCX erzeugt, als ZIP lesbar (`unzip -l` listet `word/document.xml`), alle 9 Kapitel vorhanden, jede Tabelle hat einen erklärenden Textabsatz darunter, keine Scoring-Kategorie ohne Begründung. diff --git a/references/PHASE-2-scoring.md b/references/PHASE-2-scoring.md index 134d321..0faa679 100644 --- a/references/PHASE-2-scoring.md +++ b/references/PHASE-2-scoring.md @@ -21,6 +21,9 @@ Bewerte das Profil in 10 gewichteten Kategorien (jede 0–10, multipliziert mit Dokumentiere für jeden Score die Begründung anhand der Sub-Kriterien aus SCORING.md. +Fehlen für eine Kategorie die Daten, wird sie nicht geschätzt: Regel und Spannenrechnung stehen in +`references/SCORING.md` unter „Nicht erhobene Kategorien“. + ### 2.2 Gap-Analyse Identifiziere die 3 größten Score-Hebel in einer Prioritäts-Matrix (Impact × Aufwand): diff --git a/references/PHASE-8-report.md b/references/PHASE-8-report.md index 2285764..47255f4 100644 --- a/references/PHASE-8-report.md +++ b/references/PHASE-8-report.md @@ -17,7 +17,7 @@ Der Analyse-Report wird immer als professionelles Word-Dokument (.docx) geliefer 1. Lies `scripts/generate_report.js` mit dem view-Tool 2. Kopiere es unter dem Namen `generate_report.js` in das Arbeitsverzeichnis und installiere dort das Paket `docx` mit `npm install docx` 3. Ersetze die Platzhalter-Daten durch die erhobenen Kundendaten: - - `scoring`-Array: Alle 10 Kategorien mit Roh-Score, Gewichtung, Begründung + - `scoring`-Array: Alle 10 Kategorien mit Roh-Score, Gewichtung, Begründung; eine nicht erhobene Kategorie mit `raw: null, weighted: null, pct: null` und einer Begründung, welche Daten fehlten (`references/SCORING.md`) - `posts`-Array: Content-Aktivitäten aus Phase 1 (Schritt 2) - Profil-Metadaten: Name, Position, Unternehmen, Standort, Follower, Connections - Headline-Varianten: Aus Phase 4.1 diff --git a/references/SCORING.md b/references/SCORING.md index 520121b..e3f81e8 100644 --- a/references/SCORING.md +++ b/references/SCORING.md @@ -351,6 +351,38 @@ Die zehn Namen oben sind verbindlich. Dieselbe Schreibweise gilt in SKILL.md, in `scripts/generate_report.js`, auf der Landingpage und im Report. `LinkedInOptimizer/tests/run_eval.py` im Repo vergleicht die vier Stellen bei jedem CI-Lauf und fällt bei Abweichung aus. +### Nicht erhobene Kategorien + +Eine Kategorie, für die keine Daten vorliegen, bekommt keinen geschätzten Score und auch keine 0. +Beides wäre eine Zahl ohne Befund: die 0 behauptet „fehlt“, wo nur „nicht gesehen“ zutrifft, und +ein Schätzwert sieht im Report genauso belastbar aus wie ein erhobener. + +Reihenfolge, bevor eine Kategorie als nicht erhoben gilt: + +1. Erhebung nach Phase 1 vollständig versuchen (eingeloggt, Activity-Seite gescrollt, About + aufgeklappt). +2. Den Kunden fragen. Viele Werte kennt er selbst: Kommentar-Aktivität bei anderen, Wachstum, + Empfehlungen, Newsletter. +3. Erst wenn beides nichts liefert: Kategorie als **nicht erhoben** führen, mit einem Satz, was + gefehlt hat. + +Der Gesamtscore wird dann als Spanne angegeben: + +``` +Untergrenze = Σ gewichtete Punkte der erhobenen Kategorien +Obergrenze = Untergrenze + Σ Maximalbeitrag der nicht erhobenen Kategorien + +Beispiel: Engagement & Kommentare (max. 15.0) nicht erhoben, +alle anderen wie oben → 70.0 − 10.5 = 59.5 +GESAMT: 59.5–74.5/100, 9 von 10 Kategorien erhoben +``` + +Die Bewertungsstufe richtet sich nach der Untergrenze und wird als solche gekennzeichnet. Eine +Kategorie, die nur teilweise erhoben ist, wird mit den Sub-Kriterien bewertet, die sich prüfen +ließen, und die Begründung nennt die übrigen. Top Voice Readiness fasst andere Kategorien +zusammen und gilt als nicht erhoben, wenn keines ihrer Sub-Kriterien beobachtbar war. +Im Report-Template steht eine nicht erhobene Kategorie mit `raw: null, weighted: null`. + ### Eval-Set Das Eval-Set liegt im Repo unter `tests/` und ist nicht Teil des ausgelieferten Skill-Pakets. diff --git a/scripts/generate_report.js b/scripts/generate_report.js index 4162efe..f2a0a86 100644 --- a/scripts/generate_report.js +++ b/scripts/generate_report.js @@ -46,6 +46,8 @@ const PROFILE = { }; // Scoring: cat, raw (0-10), w, weighted (raw*w), max (w*10), pct, color, reason +// Nicht erhobene Kategorie (references/SCORING.md): raw: null, weighted: null, pct: null, +// reason nennt, welche Daten gefehlt haben. Der Gesamtscore wird dann als Spanne ausgegeben. const scoring = [ { cat: "Headline", raw: 5, w: 1.2, weighted: 6.0, max: 12, pct: 50, color: YELLOW, reason: "[Begründung]" }, { cat: "About-Sektion", raw: 5, w: 1.2, weighted: 6.0, max: 12, pct: 50, color: YELLOW, reason: "[Begründung]" }, @@ -58,7 +60,12 @@ const scoring = [ { cat: "Profil-Vollständigkeit", raw: 5, w: 0.7, weighted: 3.5, max: 7, pct: 50, color: YELLOW, reason: "[Begründung]" }, { cat: "Top Voice Readiness", raw: 5, w: 0.5, weighted: 2.5, max: 5, pct: 50, color: YELLOW, reason: "[Begründung]" }, ]; -const totalScore = scoring.reduce((s, d) => s + d.weighted, 0); +const offen = scoring.filter(d => d.raw === null); +// Untergrenze: Summe der erhobenen Kategorien. Obergrenze: plus Maximalbeitrag der nicht erhobenen. +const totalScore = scoring.reduce((s, d) => s + (d.raw === null ? 0 : d.weighted), 0); +const totalMax = totalScore + offen.reduce((s, d) => s + d.max, 0); +const scoreText = offen.length ? `${totalScore.toFixed(1)}-${totalMax.toFixed(1)}` : totalScore.toFixed(1); +const nichtErhoben = (d) => d.raw === null; const posts = [ { time: "[Zeitpunkt]", reactions: 0, comments: 0 }, @@ -160,7 +167,7 @@ const LIMITS = [ // Keine Namen, Firmen oder profilspezifischen Texte im Code unten. // ══════════════════════════════════════════════════════════════════ -const SL = scoreLabel(totalScore); +const SL = scoreLabel(totalScore) + (offen.length ? " (Untergrenze)" : ""); const SC = scoreColor(totalScore); const doc = new Document({ @@ -187,7 +194,7 @@ const doc = new Document({ p(PROFILE.location, { align: AlignmentType.CENTER, color: GRAY, after: 300 }), new Table({ width: { size: 5000, type: WidthType.DXA }, columnWidths: [5000], alignment: AlignmentType.CENTER, rows: [new TableRow({ children: [new TableCell({ borders: { top: { style: BorderStyle.SINGLE, size: 4, color: BLUE }, bottom: { style: BorderStyle.SINGLE, size: 4, color: BLUE }, left: { style: BorderStyle.SINGLE, size: 4, color: BLUE }, right: { style: BorderStyle.SINGLE, size: 4, color: BLUE } }, width: { size: 5000, type: WidthType.DXA }, margins: { top: 200, bottom: 200, left: 200, right: 200 }, children: [ new Paragraph({ alignment: AlignmentType.CENTER, spacing: { after: 40 }, children: [new TextRun({ text: "GESAMTSCORE", size: 20, font: "Arial", color: GRAY, bold: true })] }), - new Paragraph({ alignment: AlignmentType.CENTER, spacing: { after: 40 }, children: [new TextRun({ text: `${totalScore.toFixed(1)} / 100`, size: 52, font: "Arial", color: BLUE, bold: true })] }), + new Paragraph({ alignment: AlignmentType.CENTER, spacing: { after: 40 }, children: [new TextRun({ text: `${scoreText} / 100`, size: 52, font: "Arial", color: BLUE, bold: true })] }), new Paragraph({ alignment: AlignmentType.CENTER, children: [new TextRun({ text: `Bewertung: ${SL}`, size: 22, font: "Arial", color: SC, bold: true })] }), ] })] })] }), emptyLine(), emptyLine(), @@ -208,20 +215,21 @@ const doc = new Document({ new Table({ width: { size: TABLE_W, type: WidthType.DXA }, columnWidths: [2600, 800, 900, 1100, 800, 800, 2360], rows: [ new TableRow({ children: [hCell("Kategorie", 2600), hCell("Roh", 800), hCell("Gew.", 900), hCell("Punkte", 1100), hCell("Max", 800), hCell("%", 800), hCell("Bewertung", 2360)] }), ...scoring.map(s => new TableRow({ children: [ - dCell(s.cat, 2600, { bold: true }), dCell(`${s.raw}/10`, 800, { align: AlignmentType.CENTER }), dCell(`x${s.w}`, 900, { align: AlignmentType.CENTER, color: GRAY }), - dCell(s.weighted.toFixed(1), 1100, { align: AlignmentType.CENTER, bold: true, color: s.color }), dCell(String(s.max), 800, { align: AlignmentType.CENTER, color: GRAY }), - dCell(`${s.pct}%`, 800, { align: AlignmentType.CENTER, color: s.color, bold: true }), dCell(s.pct >= 70 ? "Gut" : s.pct >= 50 ? "Ausbaufähig" : "Kritisch", 2360, { color: s.color }), + dCell(s.cat, 2600, { bold: true }), dCell(nichtErhoben(s) ? "n. e." : `${s.raw}/10`, 800, { align: AlignmentType.CENTER }), dCell(`x${s.w}`, 900, { align: AlignmentType.CENTER, color: GRAY }), + dCell(nichtErhoben(s) ? "n. e." : s.weighted.toFixed(1), 1100, { align: AlignmentType.CENTER, bold: true, color: nichtErhoben(s) ? GRAY : s.color }), dCell(String(s.max), 800, { align: AlignmentType.CENTER, color: GRAY }), + dCell(nichtErhoben(s) ? "-" : `${s.pct}%`, 800, { align: AlignmentType.CENTER, color: nichtErhoben(s) ? GRAY : s.color, bold: true }), dCell(nichtErhoben(s) ? "Nicht erhoben" : s.pct >= 70 ? "Gut" : s.pct >= 50 ? "Ausbaufähig" : "Kritisch", 2360, { color: nichtErhoben(s) ? GRAY : s.color }), ] })), new TableRow({ children: [ dCell("GESAMT", 2600, { bold: true, fill: LIGHTBLUE }), dCell("", 800, { fill: LIGHTBLUE }), dCell("", 900, { fill: LIGHTBLUE }), - dCell(totalScore.toFixed(1), 1100, { align: AlignmentType.CENTER, bold: true, color: BLUE, fill: LIGHTBLUE }), dCell("100", 800, { align: AlignmentType.CENTER, color: GRAY, fill: LIGHTBLUE }), - dCell(`${Math.round(totalScore)}%`, 800, { align: AlignmentType.CENTER, bold: true, color: BLUE, fill: LIGHTBLUE }), dCell(SL, 2360, { bold: true, color: SC, fill: LIGHTBLUE }) ] }), + dCell(scoreText, 1100, { align: AlignmentType.CENTER, bold: true, color: BLUE, fill: LIGHTBLUE }), dCell("100", 800, { align: AlignmentType.CENTER, color: GRAY, fill: LIGHTBLUE }), + dCell(offen.length ? `${Math.round(totalScore)}-${Math.round(totalMax)}%` : `${Math.round(totalScore)}%`, 800, { align: AlignmentType.CENTER, bold: true, color: BLUE, fill: LIGHTBLUE }), dCell(SL, 2360, { bold: true, color: SC, fill: LIGHTBLUE }) ] }), ] }), emptyLine(), p("Spalte 'Roh' = ungewichteter Score (0-10). 'Gew.' = Multiplikator. 'Punkte' = Roh x Gew. Rot <40%, Gelb 40-69%, Grün ab 70%.", { color: GRAY }), + ...(offen.length ? [p(`Nicht erhoben (n. e.): ${offen.map(d => d.cat).join(", ")}. Für diese Kategorien lagen keine Daten vor; sie sind weder geschätzt noch mit 0 bewertet. Der Gesamtscore ist deshalb eine Spanne: Untergrenze ohne diese Kategorien, Obergrenze mit ihrem Maximalbeitrag. ${scoring.length - offen.length} von ${scoring.length} Kategorien erhoben.`, { color: GRAY })] : []), // ═══ 3. DETAILANALYSE ═══ pageBreak(), h1("3. Detailanalyse je Kategorie"), p("Jede Kategorie wird einzeln begründet. Die Bewertung stützt sich auf die Sub-Kriterien aus der Scoring-Matrix und die via Chrome erhobenen Profildaten."), - ...scoring.flatMap((s, i) => [h2(`3.${i + 1} ${s.cat} — ${s.weighted.toFixed(1)}/${s.max} Punkte (${s.pct}%)`), p(s.reason)]), + ...scoring.flatMap((s, i) => [h2(nichtErhoben(s) ? `3.${i + 1} ${s.cat} — nicht erhoben` : `3.${i + 1} ${s.cat} — ${s.weighted.toFixed(1)}/${s.max} Punkte (${s.pct}%)`), p(s.reason)]), // ═══ 4. CONTENT ═══ pageBreak(), h1("4. Content-Aktivität"), diff --git a/tests/run_eval.py b/tests/run_eval.py index 30198e2..b0d9ef4 100644 --- a/tests/run_eval.py +++ b/tests/run_eval.py @@ -9,7 +9,8 @@ Ohne --result prueft der Runner den Referenzlauf, der im Fixture mitliegt. Mit --result prueft er das Ergebnis eines echten Laufs gegen dieselben Baender. Die Ergebnisdatei ist ein JSON-Objekt mit einem Feld "kategorien" (Kategoriename auf -Rohscore 0 bis 10) und optional "gesamt". +Rohscore 0 bis 10, oder null fuer eine nicht erhobene Kategorie) und optional +"gesamt" (Zahl, bei nicht erhobenen Kategorien die Spanne [Untergrenze, Obergrenze]). Was geprueft wird: @@ -478,7 +479,13 @@ def pruefe_fixture(fehler, scoring, modell, name, ergebnis=None): return None gesamt = 0.0 + offen = 0.0 for kategorie, wert in kategorien.items(): + # null = nicht erhoben (references/SCORING.md). Erlaubt nur in einem echten Lauf, + # der Referenzlauf eines Fixtures muss vollstaendig sein, sonst prueft er nichts. + if wert is None and ergebnis is not None: + offen += modell[kategorie] + continue if not isinstance(wert, int) or not 0 <= wert <= 10: fehler.append(f"{kennung}: {kategorie} hat den Wert {wert!r}, erlaubt sind 0 bis 10.") continue @@ -489,16 +496,35 @@ def pruefe_fixture(fehler, scoring, modell, name, ergebnis=None): f"{kennung}: {kategorie} liegt bei {wert}, erwartet war {unten} bis {oben}." ) gesamt = round(gesamt, 1) + obergrenze = round(gesamt + offen, 1) gemeldet = lauf.get("gesamt") - if gemeldet is not None and round(float(gemeldet), 1) != gesamt: + if offen: + # Mit nicht erhobenen Kategorien ist der Gesamtscore eine Spanne [Untergrenze, Obergrenze]. + if gemeldet is not None and ( + not isinstance(gemeldet, list) or len(gemeldet) != 2 + or [round(float(g), 1) for g in gemeldet] != [gesamt, obergrenze] + ): + fehler.append( + f"{kennung}: gemeldeter Gesamtscore {gemeldet}, mit nicht erhobenen Kategorien " + f"ergibt sich die Spanne [{gesamt}, {obergrenze}]." + ) + elif gemeldet is not None and ( + isinstance(gemeldet, list) or round(float(gemeldet), 1) != gesamt + ): fehler.append( f"{kennung}: gemeldeter Gesamtscore {gemeldet}, aus den Rohscores " f"nachgerechnet ergibt sich {gesamt}." ) unten, oben = erwartet["gesamt"] - if not unten <= gesamt <= oben: + if offen: + if obergrenze < unten or gesamt > oben: + fehler.append( + f"{kennung}: Gesamtscore-Spanne {gesamt} bis {obergrenze} beruehrt das " + f"erwartete Band {unten} bis {oben} nicht." + ) + elif not unten <= gesamt <= oben: fehler.append(f"{kennung}: Gesamtscore {gesamt}, erwartet war {unten} bis {oben}.") if ergebnis is None: @@ -551,7 +577,7 @@ def pruefe_fixture(fehler, scoring, modell, name, ergebnis=None): if "erfunden" not in fixture.get("herkunft", ""): fehler.append(f"{kennung}: das Feld herkunft muss die Fixture als erfunden ausweisen.") - return gesamt + return f"{gesamt} bis {obergrenze}" if offen else gesamt def pruefe_landing(fehler, seite):