Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
3 changes: 3 additions & 0 deletions references/PHASE-2-scoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
2 changes: 1 addition & 1 deletion references/PHASE-8-report.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 32 additions & 0 deletions references/SCORING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
26 changes: 17 additions & 9 deletions scripts/generate_report.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]" },
Expand All @@ -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 },
Expand Down Expand Up @@ -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({
Expand All @@ -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(),
Expand All @@ -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"),
Expand Down
34 changes: 30 additions & 4 deletions tests/run_eval.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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):
Expand Down