Migrate all repos into monorepo context folders

Bahn: aisupport, Analyse-O2C-C2S, awesome-bahn-mcp-servers, beam-mcp,
      Confluence_Bot, db-planet-mcp-server, O2C-Harness, project-audit,
      Projekt-KIQ-HP, teamlandkarte-mcp
Dhive: Jury-Voting
Privat: CV, NoteGraph (NOTE: NoteGraph needs complete redo after consolidation)
Shared: AI-Orchestrator, OrgMyLife, power_skills_and_more
Shared/references: symphony (read-only)

Bahn repos remain available as independent remotes - this monorepo
pulls them in via subtree, the originals are untouched.
This commit is contained in:
2026-06-30 20:39:52 +02:00
parent 2f2b295531
commit a5f8fb49ab
1717 changed files with 447332 additions and 0 deletions
@@ -0,0 +1,199 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { generateAbout } from './about-generator';
import { ABOUT_CHAR_LIMIT } from './constraints';
// Mock the entity-files module
vi.mock('../io/entity-files', () => ({
readEntity: vi.fn(),
listEntities: vi.fn(),
}));
import { readEntity, listEntities } from '../io/entity-files';
const mockReadEntity = vi.mocked(readEntity);
const mockListEntities = vi.mocked(listEntities);
// --- Test Data ---
const mockPerson = {
id: 'andre-knie',
type: 'person' as const,
created: '2024-01-01',
modified: '2024-06-01',
name: { first: 'Andre', last: 'Knie', display: 'Dr. Andre Knie' },
summary: 'Experte für KI, Digitalisierung und Change Management',
education: [
{ institution: 'Universität Kassel', degree: 'Dr. rer. nat.', field: 'Physik', start: '2010', end: '2014' },
],
experiences: ['db-infrago-digitalisierung', 'data-hive-cassel-gruender'],
skills: ['kuenstliche-intelligenz', 'change-management'],
};
const mockProject1 = {
id: 'ki-fraitag',
type: 'project' as const,
created: '2024-01-01',
modified: '2024-06-01',
name: 'KI-Fraitag',
description: 'Regelmäßiges KI-Meetup in Nordhessen',
};
const mockProject2 = {
id: 'powerhive-energiemonitoring',
type: 'project' as const,
created: '2024-01-01',
modified: '2024-06-01',
name: 'PowerHive Energiemonitoring',
description: 'KI-basiertes Energiemonitoring für KMU',
};
// --- Setup ---
function setupMocks() {
mockReadEntity.mockImplementation(async (type, id) => {
if (type === 'person' && id === 'andre-knie') return mockPerson as any;
if (type === 'project' && id === 'ki-fraitag') return mockProject1 as any;
if (type === 'project' && id === 'powerhive-energiemonitoring') return mockProject2 as any;
throw new Error(`Entity not found: ${type} "${id}"`);
});
mockListEntities.mockImplementation(async (type) => {
if (type === 'project') return ['ki-fraitag', 'powerhive-energiemonitoring'];
return [];
});
}
// --- Tests ---
describe('generateAbout', () => {
beforeEach(() => {
vi.clearAllMocks();
setupMocks();
});
describe('Zeichenlimit-Einhaltung', () => {
it('generierter Text bleibt unter 2.600 Zeichen', async () => {
const result = await generateAbout({ personId: 'andre-knie' });
expect(result.fullText.length).toBeLessThanOrEqual(ABOUT_CHAR_LIMIT);
});
it('charCount stimmt mit tatsächlicher Textlänge überein', async () => {
const result = await generateAbout({ personId: 'andre-knie' });
expect(result.charCount).toBe(result.fullText.length);
});
it('Validierung ist valid wenn Text unter dem Limit ist', async () => {
const result = await generateAbout({ personId: 'andre-knie' });
expect(result.validation.valid).toBe(true);
expect(result.validation.errors).toHaveLength(0);
});
});
describe('Vorhandensein aller Sektionen', () => {
it('enthält alle vier Sektionen (hook, mission, expertise, cta)', async () => {
const result = await generateAbout({ personId: 'andre-knie' });
expect(result.sections.hook).toBeDefined();
expect(result.sections.hook.length).toBeGreaterThan(0);
expect(result.sections.mission).toBeDefined();
expect(result.sections.mission.length).toBeGreaterThan(0);
expect(result.sections.expertise).toBeDefined();
expect(result.sections.expertise.length).toBeGreaterThan(0);
expect(result.sections.cta).toBeDefined();
expect(result.sections.cta.length).toBeGreaterThan(0);
});
it('fullText enthält Inhalte aller Sektionen', async () => {
const result = await generateAbout({ personId: 'andre-knie' });
// Each section's content should appear in the full text
expect(result.fullText).toContain(result.sections.hook);
expect(result.fullText).toContain(result.sections.mission);
expect(result.fullText).toContain(result.sections.expertise);
expect(result.fullText).toContain(result.sections.cta);
});
});
describe('Hook-Preview-Extraktion', () => {
it('hookPreview enthält die ersten 2 Zeilen des Hooks', async () => {
const result = await generateAbout({ personId: 'andre-knie' });
const hookLines = result.sections.hook.split('\n');
const expectedPreview = hookLines.slice(0, 2).join('\n');
expect(result.hookPreview).toBe(expectedPreview);
});
it('hookPreview ist nicht leer', async () => {
const result = await generateAbout({ personId: 'andre-knie' });
expect(result.hookPreview.length).toBeGreaterThan(0);
});
});
describe('Tone-Modi', () => {
it('nahbar-Modus generiert gültigen About-Text', async () => {
const result = await generateAbout({ personId: 'andre-knie', tone: 'nahbar' });
expect(result.fullText.length).toBeGreaterThan(0);
expect(result.fullText.length).toBeLessThanOrEqual(ABOUT_CHAR_LIMIT);
expect(result.validation.valid).toBe(true);
});
it('fachlich-Modus generiert gültigen About-Text', async () => {
const result = await generateAbout({ personId: 'andre-knie', tone: 'fachlich' });
expect(result.fullText.length).toBeGreaterThan(0);
expect(result.fullText.length).toBeLessThanOrEqual(ABOUT_CHAR_LIMIT);
expect(result.validation.valid).toBe(true);
});
it('inspirierend-Modus generiert gültigen About-Text', async () => {
const result = await generateAbout({ personId: 'andre-knie', tone: 'inspirierend' });
expect(result.fullText.length).toBeGreaterThan(0);
expect(result.fullText.length).toBeLessThanOrEqual(ABOUT_CHAR_LIMIT);
expect(result.validation.valid).toBe(true);
});
it('verschiedene Tone-Modi erzeugen unterschiedliche Texte', async () => {
const nahbar = await generateAbout({ personId: 'andre-knie', tone: 'nahbar' });
const fachlich = await generateAbout({ personId: 'andre-knie', tone: 'fachlich' });
const inspirierend = await generateAbout({ personId: 'andre-knie', tone: 'inspirierend' });
expect(nahbar.fullText).not.toBe(fachlich.fullText);
expect(nahbar.fullText).not.toBe(inspirierend.fullText);
expect(fachlich.fullText).not.toBe(inspirierend.fullText);
});
});
describe('includeStats Option', () => {
it('enthält Statistiken wenn includeStats=true (default)', async () => {
const result = await generateAbout({ personId: 'andre-knie', includeStats: true });
// Stats should contain publication count, years of AI experience, project count
expect(result.fullText).toMatch(/60\+/);
expect(result.fullText).toMatch(/\d+\+.*Jahre/);
});
it('enthält keine Statistiken wenn includeStats=false', async () => {
const result = await generateAbout({ personId: 'andre-knie', includeStats: false });
// The stats line with 📊 should not be present
expect(result.fullText).not.toContain('📊');
});
it('default includeStats ist true', async () => {
const result = await generateAbout({ personId: 'andre-knie' });
// Default should include stats
expect(result.fullText).toContain('📊');
});
});
});
+234
View File
@@ -0,0 +1,234 @@
/**
* LinkedIn About Section Generator
*
* Generates an optimized LinkedIn About section from KB data.
* Reads person profile and projects, builds structured sections
* (Hook, Mission, Expertise, CTA), and validates against LinkedIn constraints.
*/
import type { Person, Project } from '../schemas/types';
import type { AboutOptions, AboutResult } from './types';
import { validateAbout, ABOUT_CHAR_LIMIT } from './constraints';
import { readEntity, listEntities } from '../io/entity-files';
// --- Internal Types ---
interface ProfileData {
person: Person;
projects: Project[];
stats: ProfileStats;
}
interface ProfileStats {
publications: string;
yearsAI: string;
projects: string;
}
// --- Tone Configuration ---
type ToneType = NonNullable<AboutOptions['tone']>;
interface ToneConfig {
hookStyle: 'question' | 'statement' | 'vision';
missionIntro: string;
ctaStyle: 'invitation' | 'challenge' | 'offer';
}
const TONE_CONFIGS: Record<ToneType, ToneConfig> = {
nahbar: {
hookStyle: 'question',
missionIntro: 'Mein Antrieb:',
ctaStyle: 'invitation',
},
fachlich: {
hookStyle: 'statement',
missionIntro: 'Meine Mission:',
ctaStyle: 'offer',
},
inspirierend: {
hookStyle: 'vision',
missionIntro: 'Wofür ich stehe:',
ctaStyle: 'challenge',
},
};
// --- Core Themes ---
const CORE_THEMES = [
'Innovation & Technologie',
'Mensch & Kultur',
'Verantwortung',
] as const;
// --- Public API ---
/**
* Generates a LinkedIn About section from KB data.
*
* Reads the person's profile and projects, extracts stats,
* and produces a structured About section with Hook, Mission, Expertise, and CTA.
*/
export async function generateAbout(options: AboutOptions): Promise<AboutResult> {
const { personId, tone = 'nahbar', includeStats = true } = options;
const profileData = await loadProfileData(personId);
const sections = buildSections(profileData, tone, includeStats);
const fullText = assembleSections(sections);
const hookPreview = extractHookPreview(sections.hook);
const validation = validateAbout(fullText);
return {
fullText,
charCount: fullText.length,
hookPreview,
sections,
validation,
};
}
// --- Data Loading ---
async function loadProfileData(personId: string): Promise<ProfileData> {
const person = (await readEntity('person', personId)) as Person;
const projectIds = await listEntities('project');
const projects: Project[] = [];
for (const projId of projectIds) {
try {
const project = (await readEntity('project', projId)) as Project;
projects.push(project);
} catch {
// Skip missing projects
}
}
const stats = extractStats(person, projects);
return { person, projects, stats };
}
// --- Stats Extraction ---
function extractStats(person: Person, projects: Project[]): ProfileStats {
// Publications: from person summary or known facts
const publications = '60+';
// Years of AI experience: first ML publication 2012, calculate from current year
const firstAIYear = 2012;
const currentYear = new Date().getFullYear();
const yearsAI = `${currentYear - firstAIYear}+`;
// Projects: count from KB or use known minimum
const projectCount = Math.max(projects.length, 50);
const projectsStr = `${projectCount}+`;
return { publications, yearsAI, projects: projectsStr };
}
// --- Section Building ---
function buildSections(
data: ProfileData,
tone: ToneType,
includeStats: boolean,
): { hook: string; mission: string; expertise: string; cta: string } {
const config = TONE_CONFIGS[tone];
const hook = buildHook(data, config);
const mission = buildMission(data, config);
const expertise = buildExpertise(data, config, includeStats);
const cta = buildCTA(data, config);
return { hook, mission, expertise, cta };
}
function buildHook(data: ProfileData, config: ToneConfig): string {
const { person } = data;
switch (config.hookStyle) {
case 'question':
return 'Was passiert, wenn ein Physiker Konzerninnovation und Startup-Gründung verbindet?\nGenau das lebe ich jeden Tag — an der Schnittstelle von KI, Führung und echtem Wandel.';
case 'statement':
return 'Physiker. Gründer. Digitalisierer im Konzern.\nIch verbinde wissenschaftliche Tiefe mit unternehmerischer Umsetzungskraft.';
case 'vision':
return 'Technologie soll begeistern, nicht einschüchtern.\nIch baue Brücken zwischen KI-Innovation und den Menschen, die sie nutzen.';
}
}
function buildMission(data: ProfileData, config: ToneConfig): string {
return `${config.missionIntro} Die Angst vor Technologie und Veränderung lähmt viele Organisationen. Ich helfe dabei, diese Ängste zu überwinden, und gestalte Technologie sowie Change-Prozesse so, dass sie echten Mehrwert schaffen.
Meine Arbeit bewegt sich in drei Kernthemen:
${CORE_THEMES[0]}: KI und Digitalisierung praxistauglich machen
${CORE_THEMES[1]}: Veränderung menschlich gestalten
${CORE_THEMES[2]}: Technologie ethisch und nachhaltig einsetzen`;
}
function buildExpertise(data: ProfileData, config: ToneConfig, includeStats: boolean): string {
const { stats } = data;
let expertiseText = 'Als promovierter Physiker bringe ich eine einzigartige Perspektive mit: Systeme verstehen, Zusammenhänge erkennen, Lösungen entwickeln, die funktionieren.';
if (includeStats) {
expertiseText += `\n\n📊 ${stats.publications} wissenschaftliche Publikationen | ${stats.yearsAI} Jahre KI-Erfahrung | ${stats.projects} Projekte`;
}
expertiseText += '\n\nAktuell verbinde ich zwei Welten: Als Experte für Digitalisierung bei DB InfraGO treibe ich Innovation im Konzern voran. Als Gründer von Data Hive Cassel mache ich KI für den Mittelstand zugänglich.';
return expertiseText;
}
function buildCTA(data: ProfileData, config: ToneConfig): string {
switch (config.ctaStyle) {
case 'invitation':
return 'Lassen Sie uns vernetzen — ich freue mich auf den Austausch über KI, Digitalisierung und neue Wege in der Führung. Schreiben Sie mir gerne eine Nachricht!';
case 'offer':
return 'Sie stehen vor einer digitalen Transformation oder wollen KI strategisch einsetzen? Lassen Sie uns sprechen — ich teile meine Erfahrung gerne.';
case 'challenge':
return 'Bereit, Technologie als Chance statt als Bedrohung zu sehen? Vernetzen Sie sich mit mir — gemeinsam gestalten wir die digitale Zukunft.';
}
}
// --- Assembly ---
function assembleSections(sections: { hook: string; mission: string; expertise: string; cta: string }): string {
const parts = [sections.hook, sections.mission, sections.expertise, sections.cta];
let fullText = parts.join('\n\n');
// Ensure we stay within the character limit
if (fullText.length > ABOUT_CHAR_LIMIT) {
fullText = trimToLimit(fullText);
}
return fullText;
}
// --- Hook Preview ---
function extractHookPreview(hook: string): string {
// Return the first 2 lines of the hook (visible before "mehr anzeigen")
const lines = hook.split('\n');
return lines.slice(0, 2).join('\n');
}
// --- Constraint Helpers ---
function trimToLimit(text: string): string {
if (text.length <= ABOUT_CHAR_LIMIT) {
return text;
}
// Truncate at last paragraph break before limit
const truncated = text.slice(0, ABOUT_CHAR_LIMIT - 1);
const lastParagraph = truncated.lastIndexOf('\n\n');
if (lastParagraph > ABOUT_CHAR_LIMIT * 0.7) {
return truncated.slice(0, lastParagraph) + '\n\n…';
}
// Fallback: truncate at last space
const lastSpace = truncated.lastIndexOf(' ');
if (lastSpace > ABOUT_CHAR_LIMIT * 0.8) {
return truncated.slice(0, lastSpace) + '…';
}
return truncated + '…';
}
+188
View File
@@ -0,0 +1,188 @@
import { describe, it, expect } from 'vitest';
import {
validateHeadline,
validateAbout,
validateExperience,
HEADLINE_CHAR_LIMIT,
ABOUT_CHAR_LIMIT,
EXPERIENCE_CHAR_LIMIT,
} from './constraints';
// --- Headline Validation ---
describe('validateHeadline', () => {
it('returns valid for text under 220 chars', () => {
const text = 'KI & Digitalisierung | DB InfraGO | Data Hive Cassel';
const result = validateHeadline(text);
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
});
it('returns error for text over 220 chars', () => {
const text = 'a'.repeat(221);
const result = validateHeadline(text);
expect(result.valid).toBe(false);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].field).toBe('headline');
expect(result.errors[0].constraint).toBe('maxLength');
expect(result.errors[0].actual).toBe(221);
expect(result.errors[0].limit).toBe(HEADLINE_CHAR_LIMIT);
});
it('returns valid at exactly 220 chars (with warning since above 90%)', () => {
const text = 'a'.repeat(220);
const result = validateHeadline(text);
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
// 220 > 198 (90% threshold), so a warning is expected
expect(result.warnings).toHaveLength(1);
});
it('returns warning when at 90%+ of limit (199+ chars)', () => {
// 90% of 220 = 198, so 199 chars should trigger warning
const text = 'a'.repeat(199);
const result = validateHeadline(text);
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
expect(result.warnings).toHaveLength(1);
expect(result.warnings[0].field).toBe('headline');
expect(result.warnings[0].actual).toBe(199);
expect(result.warnings[0].limit).toBe(HEADLINE_CHAR_LIMIT);
});
it('returns error for empty string (required)', () => {
const result = validateHeadline('');
expect(result.valid).toBe(false);
expect(result.errors.some(e => e.constraint === 'required')).toBe(true);
});
});
// --- About Validation ---
describe('validateAbout', () => {
it('returns valid for text under 2600 chars', () => {
const text = 'Ich helfe Unternehmen, KI sinnvoll einzusetzen.';
const result = validateAbout(text);
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
});
it('returns error for text over 2600 chars', () => {
const text = 'a'.repeat(2601);
const result = validateAbout(text);
expect(result.valid).toBe(false);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].field).toBe('about');
expect(result.errors[0].constraint).toBe('maxLength');
expect(result.errors[0].actual).toBe(2601);
expect(result.errors[0].limit).toBe(ABOUT_CHAR_LIMIT);
});
it('returns valid at exactly 2600 chars (with warning since above 90%)', () => {
const text = 'a'.repeat(2600);
const result = validateAbout(text);
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
// 2600 > 2340 (90% threshold), so a warning is expected
expect(result.warnings).toHaveLength(1);
});
it('returns warning when at 90%+ of limit (2341+ chars)', () => {
// 90% of 2600 = 2340, so 2341 chars should trigger warning
const text = 'a'.repeat(2341);
const result = validateAbout(text);
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
expect(result.warnings).toHaveLength(1);
expect(result.warnings[0].field).toBe('about');
expect(result.warnings[0].actual).toBe(2341);
expect(result.warnings[0].limit).toBe(ABOUT_CHAR_LIMIT);
});
it('returns error for empty string (required)', () => {
const result = validateAbout('');
expect(result.valid).toBe(false);
expect(result.errors.some(e => e.constraint === 'required')).toBe(true);
});
});
// --- Experience Validation ---
describe('validateExperience', () => {
it('returns valid for text under 2000 chars', () => {
const text = 'Leitung der Digitalisierungsstrategie mit messbaren Ergebnissen.';
const result = validateExperience(text);
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
});
it('returns error for text over 2000 chars', () => {
const text = 'a'.repeat(2001);
const result = validateExperience(text);
expect(result.valid).toBe(false);
expect(result.errors).toHaveLength(1);
expect(result.errors[0].field).toBe('experience');
expect(result.errors[0].constraint).toBe('maxLength');
expect(result.errors[0].actual).toBe(2001);
expect(result.errors[0].limit).toBe(EXPERIENCE_CHAR_LIMIT);
});
it('returns valid at exactly 2000 chars (with warning since above 90%)', () => {
const text = 'a'.repeat(2000);
const result = validateExperience(text);
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
// 2000 > 1800 (90% threshold), so a warning is expected
expect(result.warnings).toHaveLength(1);
});
it('returns warning when at 90%+ of limit (1801+ chars)', () => {
// 90% of 2000 = 1800, so 1801 chars should trigger warning
const text = 'a'.repeat(1801);
const result = validateExperience(text);
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
expect(result.warnings).toHaveLength(1);
expect(result.warnings[0].field).toBe('experience');
expect(result.warnings[0].actual).toBe(1801);
expect(result.warnings[0].limit).toBe(EXPERIENCE_CHAR_LIMIT);
});
it('returns error for empty string (required)', () => {
const result = validateExperience('');
expect(result.valid).toBe(false);
expect(result.errors.some(e => e.constraint === 'required')).toBe(true);
});
});
// --- Edge Cases ---
describe('Edge Cases', () => {
it('headline at exactly the warning threshold (198 chars = 90%) has no warning', () => {
// 90% of 220 = 198 exactly — threshold is >, not >=
const text = 'a'.repeat(198);
const result = validateHeadline(text);
expect(result.valid).toBe(true);
expect(result.warnings).toHaveLength(0);
});
it('about at exactly the warning threshold (2340 chars = 90%) has no warning', () => {
const text = 'a'.repeat(2340);
const result = validateAbout(text);
expect(result.valid).toBe(true);
expect(result.warnings).toHaveLength(0);
});
it('experience at exactly the warning threshold (1800 chars = 90%) has no warning', () => {
const text = 'a'.repeat(1800);
const result = validateExperience(text);
expect(result.valid).toBe(true);
expect(result.warnings).toHaveLength(0);
});
it('single character is valid for all validators', () => {
expect(validateHeadline('X').valid).toBe(true);
expect(validateAbout('X').valid).toBe(true);
expect(validateExperience('X').valid).toBe(true);
});
});
+144
View File
@@ -0,0 +1,144 @@
/**
* LinkedIn Constraint Validator
*
* Validates LinkedIn-specific constraints for profile sections.
* Enforces character limits and provides warnings for content approaching limits.
*/
// --- Constants ---
export const HEADLINE_CHAR_LIMIT = 220;
export const ABOUT_CHAR_LIMIT = 2600;
export const EXPERIENCE_CHAR_LIMIT = 2000;
/** Threshold (percentage) at which a warning is issued for approaching the limit */
const WARNING_THRESHOLD = 0.9;
// --- Interfaces ---
export interface ConstraintError {
field: string;
constraint: string;
actual: string | number;
limit: string | number;
}
export interface ConstraintWarning {
field: string;
message: string;
actual: string | number;
limit: string | number;
}
export interface ConstraintValidation {
valid: boolean;
errors: ConstraintError[];
warnings: ConstraintWarning[];
}
// --- Validation Functions ---
/**
* Validates a LinkedIn headline against character limit (max 220 Zeichen).
*/
export function validateHeadline(text: string): ConstraintValidation {
const errors: ConstraintError[] = [];
const warnings: ConstraintWarning[] = [];
if (text.length > HEADLINE_CHAR_LIMIT) {
errors.push({
field: 'headline',
constraint: 'maxLength',
actual: text.length,
limit: HEADLINE_CHAR_LIMIT,
});
} else if (text.length > HEADLINE_CHAR_LIMIT * WARNING_THRESHOLD) {
warnings.push({
field: 'headline',
message: `Headline nähert sich dem Zeichenlimit (${text.length}/${HEADLINE_CHAR_LIMIT})`,
actual: text.length,
limit: HEADLINE_CHAR_LIMIT,
});
}
if (text.length === 0) {
errors.push({
field: 'headline',
constraint: 'required',
actual: 0,
limit: 1,
});
}
return { valid: errors.length === 0, errors, warnings };
}
/**
* Validates a LinkedIn About section against character limit (max 2.600 Zeichen).
*/
export function validateAbout(text: string): ConstraintValidation {
const errors: ConstraintError[] = [];
const warnings: ConstraintWarning[] = [];
if (text.length > ABOUT_CHAR_LIMIT) {
errors.push({
field: 'about',
constraint: 'maxLength',
actual: text.length,
limit: ABOUT_CHAR_LIMIT,
});
} else if (text.length > ABOUT_CHAR_LIMIT * WARNING_THRESHOLD) {
warnings.push({
field: 'about',
message: `About-Section nähert sich dem Zeichenlimit (${text.length}/${ABOUT_CHAR_LIMIT})`,
actual: text.length,
limit: ABOUT_CHAR_LIMIT,
});
}
if (text.length === 0) {
errors.push({
field: 'about',
constraint: 'required',
actual: 0,
limit: 1,
});
}
return { valid: errors.length === 0, errors, warnings };
}
/**
* Validates a LinkedIn Experience description against character limit (max 2.000 Zeichen).
*/
export function validateExperience(text: string): ConstraintValidation {
const errors: ConstraintError[] = [];
const warnings: ConstraintWarning[] = [];
if (text.length > EXPERIENCE_CHAR_LIMIT) {
errors.push({
field: 'experience',
constraint: 'maxLength',
actual: text.length,
limit: EXPERIENCE_CHAR_LIMIT,
});
} else if (text.length > EXPERIENCE_CHAR_LIMIT * WARNING_THRESHOLD) {
warnings.push({
field: 'experience',
message: `Experience-Beschreibung nähert sich dem Zeichenlimit (${text.length}/${EXPERIENCE_CHAR_LIMIT})`,
actual: text.length,
limit: EXPERIENCE_CHAR_LIMIT,
});
}
if (text.length === 0) {
errors.push({
field: 'experience',
constraint: 'required',
actual: 0,
limit: 1,
});
}
return { valid: errors.length === 0, errors, warnings };
}
@@ -0,0 +1,287 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { generateContentStrategy } from './content-strategy';
// Mock the entity-files module
vi.mock('../io/entity-files', () => ({
readEntity: vi.fn(),
}));
import { readEntity } from '../io/entity-files';
const mockReadEntity = vi.mocked(readEntity);
// --- Test Data ---
const mockPerson = {
id: 'andre-knie',
type: 'person' as const,
created: '2024-01-01',
modified: '2024-06-01',
name: { first: 'Andre', last: 'Knie', display: 'Dr. Andre Knie' },
summary: 'Experte für KI, Digitalisierung und Change Management. Physik-Promotion und wissenschaftliche Karriere als Fundament.',
experiences: ['db-infrago-digitalisierung', 'data-hive-cassel-gruender'],
skills: ['kuenstliche-intelligenz', 'change-management'],
};
// --- Setup ---
function setupMocks() {
mockReadEntity.mockImplementation(async (type, id) => {
if (type === 'person' && id === 'andre-knie') return mockPerson as any;
throw new Error(`Entity not found: ${type} "${id}"`);
});
}
// --- Tests ---
describe('generateContentStrategy', () => {
beforeEach(() => {
vi.clearAllMocks();
setupMocks();
});
describe('Struktur des generierten Plans', () => {
it('enthält alle erforderlichen Top-Level-Felder', async () => {
const result = await generateContentStrategy({
personId: 'andre-knie',
quarter: '2026-Q3',
});
expect(result).toHaveProperty('postingFrequency');
expect(result).toHaveProperty('themeCluster');
expect(result).toHaveProperty('weeklyPlan');
expect(result).toHaveProperty('engagementRoutine');
expect(result).toHaveProperty('formatMix');
});
it('postingFrequency ist "2 Beiträge pro Woche"', async () => {
const result = await generateContentStrategy({
personId: 'andre-knie',
quarter: '2026-Q3',
});
expect(result.postingFrequency).toBe('2 Beiträge pro Woche');
});
it('weeklyPlan enthält mindestens 2 Slots', async () => {
const result = await generateContentStrategy({
personId: 'andre-knie',
quarter: '2026-Q3',
});
expect(result.weeklyPlan.length).toBeGreaterThanOrEqual(2);
});
it('jeder WeeklySlot hat day, time, format und themeCluster', async () => {
const result = await generateContentStrategy({
personId: 'andre-knie',
quarter: '2026-Q3',
});
for (const slot of result.weeklyPlan) {
expect(slot).toHaveProperty('day');
expect(slot).toHaveProperty('time');
expect(slot).toHaveProperty('format');
expect(slot).toHaveProperty('themeCluster');
expect(slot.day.length).toBeGreaterThan(0);
expect(slot.time.length).toBeGreaterThan(0);
expect(slot.format.length).toBeGreaterThan(0);
expect(slot.themeCluster.length).toBeGreaterThan(0);
}
});
});
describe('Themen-Cluster-Vollständigkeit', () => {
it('enthält genau 3 Themen-Cluster', async () => {
const result = await generateContentStrategy({
personId: 'andre-knie',
quarter: '2026-Q3',
});
expect(result.themeCluster).toHaveLength(3);
});
it('enthält Cluster "Innovation & Technologie" mit 40% Gewichtung', async () => {
const result = await generateContentStrategy({
personId: 'andre-knie',
quarter: '2026-Q3',
});
const innovation = result.themeCluster.find((c) => c.name === 'Innovation & Technologie');
expect(innovation).toBeDefined();
expect(innovation!.weight).toBe(40);
});
it('enthält Cluster "Mensch & Kultur" mit 35% Gewichtung', async () => {
const result = await generateContentStrategy({
personId: 'andre-knie',
quarter: '2026-Q3',
});
const mensch = result.themeCluster.find((c) => c.name === 'Mensch & Kultur');
expect(mensch).toBeDefined();
expect(mensch!.weight).toBe(35);
});
it('enthält Cluster "Verantwortung" mit 25% Gewichtung', async () => {
const result = await generateContentStrategy({
personId: 'andre-knie',
quarter: '2026-Q3',
});
const verantwortung = result.themeCluster.find((c) => c.name === 'Verantwortung');
expect(verantwortung).toBeDefined();
expect(verantwortung!.weight).toBe(25);
});
it('Gewichtungen summieren sich auf 100%', async () => {
const result = await generateContentStrategy({
personId: 'andre-knie',
quarter: '2026-Q3',
});
const totalWeight = result.themeCluster.reduce((sum, c) => sum + c.weight, 0);
expect(totalWeight).toBe(100);
});
it('jeder Cluster hat mindestens 3 Topics', async () => {
const result = await generateContentStrategy({
personId: 'andre-knie',
quarter: '2026-Q3',
});
for (const cluster of result.themeCluster) {
expect(cluster.topics.length).toBeGreaterThanOrEqual(3);
}
});
});
describe('Format-Mix-Abdeckung', () => {
it('enthält alle 4 Formate (text, carousel, video, newsletter)', async () => {
const result = await generateContentStrategy({
personId: 'andre-knie',
quarter: '2026-Q3',
});
expect(result.formatMix).toHaveProperty('text');
expect(result.formatMix).toHaveProperty('carousel');
expect(result.formatMix).toHaveProperty('video');
expect(result.formatMix).toHaveProperty('newsletter');
});
it('Format-Mix summiert sich auf 100%', async () => {
const result = await generateContentStrategy({
personId: 'andre-knie',
quarter: '2026-Q3',
});
const total =
result.formatMix.text +
result.formatMix.carousel +
result.formatMix.video +
result.formatMix.newsletter;
expect(total).toBe(100);
});
it('alle Format-Anteile sind positiv', async () => {
const result = await generateContentStrategy({
personId: 'andre-knie',
quarter: '2026-Q3',
});
expect(result.formatMix.text).toBeGreaterThan(0);
expect(result.formatMix.carousel).toBeGreaterThan(0);
expect(result.formatMix.video).toBeGreaterThan(0);
expect(result.formatMix.newsletter).toBeGreaterThan(0);
});
it('Format-Mix summiert sich auch mit existingFormats auf 100%', async () => {
const result = await generateContentStrategy({
personId: 'andre-knie',
quarter: '2026-Q3',
existingFormats: ['podcast', 'column'],
});
const total =
result.formatMix.text +
result.formatMix.carousel +
result.formatMix.video +
result.formatMix.newsletter;
expect(total).toBe(100);
});
});
describe('Engagement-Routine', () => {
it('commentsPerWeek ist mindestens 5', async () => {
const result = await generateContentStrategy({
personId: 'andre-knie',
quarter: '2026-Q3',
});
expect(result.engagementRoutine.commentsPerWeek).toBeGreaterThanOrEqual(5);
});
it('enthält targetAccounts als nicht-leeres Array', async () => {
const result = await generateContentStrategy({
personId: 'andre-knie',
quarter: '2026-Q3',
});
expect(Array.isArray(result.engagementRoutine.targetAccounts)).toBe(true);
expect(result.engagementRoutine.targetAccounts.length).toBeGreaterThan(0);
});
it('enthält dailyTimeMinutes als positive Zahl', async () => {
const result = await generateContentStrategy({
personId: 'andre-knie',
quarter: '2026-Q3',
});
expect(result.engagementRoutine.dailyTimeMinutes).toBeGreaterThan(0);
});
});
describe('Integration bestehender Formate', () => {
it('integriert Podcast in den Wochenplan', async () => {
const result = await generateContentStrategy({
personId: 'andre-knie',
quarter: '2026-Q3',
existingFormats: ['podcast'],
});
// With podcast, the weekly plan should have more than the default 2 slots
expect(result.weeklyPlan.length).toBeGreaterThan(2);
});
it('integriert Kolumne in den Wochenplan', async () => {
const result = await generateContentStrategy({
personId: 'andre-knie',
quarter: '2026-Q3',
existingFormats: ['column'],
});
// With column, the weekly plan should have more than the default 2 slots
expect(result.weeklyPlan.length).toBeGreaterThan(2);
});
it('integriert beide Formate (podcast + column) in den Wochenplan', async () => {
const result = await generateContentStrategy({
personId: 'andre-knie',
quarter: '2026-Q3',
existingFormats: ['podcast', 'column'],
});
// With both formats, the weekly plan should have 4 slots (2 default + 2 extra)
expect(result.weeklyPlan.length).toBe(4);
});
it('ohne existingFormats hat der Wochenplan genau 2 Slots', async () => {
const result = await generateContentStrategy({
personId: 'andre-knie',
quarter: '2026-Q3',
});
expect(result.weeklyPlan).toHaveLength(2);
});
});
});
+199
View File
@@ -0,0 +1,199 @@
/**
* LinkedIn Content Strategy Generator
*
* Generates a structured content strategy from KB data.
* Reads person profile to understand themes and existing formats,
* and produces a quarterly content plan with posting frequency,
* theme clusters, weekly slots, engagement routine, and format mix.
*/
import type { Person } from '../schemas/types';
import type {
ContentStrategyOptions,
ContentStrategy,
ThemeCluster,
WeeklySlot,
EngagementRoutine,
FormatMix,
} from './types';
import { readEntity } from '../io/entity-files';
// --- Theme Cluster Definitions ---
const DEFAULT_THEME_CLUSTERS: ThemeCluster[] = [
{
name: 'Innovation & Technologie',
weight: 40,
topics: [
'KI-Anwendungen in der Praxis',
'Digitalisierung im Konzern',
'Neue Technologien bewerten',
'AI Act & Regulierung',
'Use Cases aus Projekten',
],
},
{
name: 'Mensch & Kultur',
weight: 35,
topics: [
'Change Management',
'Führung & Shared Leadership',
'Angst vor Technologie überwinden',
'Team-Entwicklung',
'Fehlerkultur & Lernen',
],
},
{
name: 'Verantwortung',
weight: 25,
topics: [
'Ethik in der KI',
'Nachhaltigkeit & Digitalisierung',
'DSGVO & Datenschutz',
'Gesellschaftliche Auswirkungen von KI',
'Verantwortungsvolle Innovation',
],
},
];
// --- Weekly Plan Defaults ---
const DEFAULT_WEEKLY_PLAN: WeeklySlot[] = [
{
day: 'Dienstag',
time: '08:00',
format: 'Text',
themeCluster: 'Innovation & Technologie',
},
{
day: 'Donnerstag',
time: '08:00',
format: 'Carousel',
themeCluster: 'Mensch & Kultur',
},
];
// --- Format Mix Defaults ---
const DEFAULT_FORMAT_MIX: FormatMix = {
text: 40,
carousel: 30,
video: 15,
newsletter: 15,
};
// --- Engagement Routine Defaults ---
const DEFAULT_ENGAGEMENT_ROUTINE: EngagementRoutine = {
commentsPerWeek: 5,
targetAccounts: [
'KI-Thought-Leader DACH',
'Digitalisierungs-Entscheider',
'Startup-Gründer Nordhessen',
'Bahn-Branche Innovatoren',
'Change-Management-Experten',
],
dailyTimeMinutes: 15,
};
// --- Public API ---
/**
* Generates a LinkedIn content strategy from KB data.
*
* Reads the person's profile to understand themes and existing formats,
* and produces a quarterly content plan with concrete posting slots,
* theme clusters, engagement routine, and format mix.
*/
export async function generateContentStrategy(
options: ContentStrategyOptions
): Promise<ContentStrategy> {
const { personId, existingFormats = [] } = options;
const person = (await readEntity('person', personId)) as Person;
const themeCluster = buildThemeClusters(person);
const weeklyPlan = buildWeeklyPlan(themeCluster, existingFormats);
const engagementRoutine = buildEngagementRoutine();
const formatMix = buildFormatMix(existingFormats);
return {
postingFrequency: '2 Beiträge pro Woche',
themeCluster,
weeklyPlan,
engagementRoutine,
formatMix,
};
}
// --- Theme Cluster Building ---
function buildThemeClusters(person: Person): ThemeCluster[] {
const clusters = DEFAULT_THEME_CLUSTERS.map((cluster) => ({ ...cluster, topics: [...cluster.topics] }));
// Enrich topics from person's summary keywords
if (person.summary) {
if (person.summary.includes('Physik') || person.summary.includes('wissenschaftlich')) {
clusters[0].topics.push('Wissenschaft trifft Wirtschaft');
}
if (person.summary.includes('Angst') || person.summary.includes('Begeisterung')) {
clusters[1].topics.push('Von der Angst zur Begeisterung');
}
}
return clusters;
}
// --- Weekly Plan Building ---
function buildWeeklyPlan(clusters: ThemeCluster[], existingFormats: string[]): WeeklySlot[] {
const plan: WeeklySlot[] = [...DEFAULT_WEEKLY_PLAN];
// Integrate existing formats into the weekly plan
if (existingFormats.includes('podcast')) {
plan.push({
day: 'Montag',
time: '12:00',
format: 'Text',
themeCluster: 'Innovation & Technologie',
});
}
if (existingFormats.includes('column')) {
plan.push({
day: 'Sonntag',
time: '10:00',
format: 'Newsletter',
themeCluster: 'Verantwortung',
});
}
return plan;
}
// --- Engagement Routine Building ---
function buildEngagementRoutine(): EngagementRoutine {
return { ...DEFAULT_ENGAGEMENT_ROUTINE, targetAccounts: [...DEFAULT_ENGAGEMENT_ROUTINE.targetAccounts] };
}
// --- Format Mix Building ---
function buildFormatMix(existingFormats: string[]): FormatMix {
const mix = { ...DEFAULT_FORMAT_MIX };
// Adjust mix when existing formats provide additional content sources
if (existingFormats.includes('podcast')) {
// Podcast episodes can be repurposed as video/carousel content
mix.video += 5;
mix.text -= 5;
}
if (existingFormats.includes('column')) {
// Column content feeds newsletter and text posts
mix.newsletter += 5;
mix.carousel -= 5;
}
return mix;
}
@@ -0,0 +1,418 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { generateExperiences } from './experience-generator';
import { EXPERIENCE_CHAR_LIMIT } from './constraints';
// Mock the entity-files module
vi.mock('../io/entity-files', () => ({
readEntity: vi.fn(),
listEntities: vi.fn(),
}));
import { readEntity } from '../io/entity-files';
const mockReadEntity = vi.mocked(readEntity);
// --- Test Data ---
const mockPerson = {
id: 'andre-knie',
type: 'person' as const,
created: '2024-01-01',
modified: '2024-06-01',
name: { first: 'Andre', last: 'Knie', display: 'Dr. Andre Knie' },
summary: 'Experte für KI, Digitalisierung und Change Management',
experiences: [
'db-infrago-digitalisierung',
'data-hive-cassel-gruender',
'db-netz-einfachbahn-jobsharing',
],
skills: ['kuenstliche-intelligenz', 'change-management'],
};
const mockExperienceInfraGO = {
id: 'db-infrago-digitalisierung',
type: 'experience' as const,
created: '2024-01-01',
modified: '2024-06-01',
title: 'Experte für Digitalisierung',
organization: 'db-infrago',
start: '2023-01',
end: null,
description: 'Digitale Transformation der Schieneninfrastruktur',
achievements: ['Digitale Transformation vorangetrieben'],
skillsUsed: ['kuenstliche-intelligenz', 'change-management', 'digitale-transformation'],
projects: [],
};
const mockExperienceDataHive = {
id: 'data-hive-cassel-gruender',
type: 'experience' as const,
created: '2024-01-01',
modified: '2024-06-01',
title: 'Gründer & Geschäftsführer',
organization: 'data-hive-cassel',
start: '2021-01',
end: null,
description: 'KI-Beratung und Prozessautomatisierung',
achievements: ['50+ Projekte umgesetzt', '15+ Kunden betreut'],
skillsUsed: ['kuenstliche-intelligenz', 'agilitaet', 'coaching'],
projects: [],
};
const mockExperienceJobSharing = {
id: 'db-netz-einfachbahn-jobsharing',
type: 'experience' as const,
created: '2024-01-01',
modified: '2024-06-01',
title: 'Leiter #Einfachbahn (JobSharing-Tandem)',
organization: 'db-netz',
start: '2020-01',
end: '2022-12',
description: 'Shared Leadership im Konzernumfeld',
achievements: ['Shared Leadership etabliert'],
skillsUsed: ['shared-leadership', 'agilitaet'],
projects: [],
};
const mockOrgInfraGO = {
id: 'db-infrago',
type: 'organization' as const,
created: '2024-01-01',
modified: '2024-06-01',
name: 'DB InfraGO',
industry: 'Transport & Infrastruktur',
};
const mockOrgDataHive = {
id: 'data-hive-cassel',
type: 'organization' as const,
created: '2024-01-01',
modified: '2024-06-01',
name: 'Data Hive Cassel',
industry: 'KI-Beratung',
};
const mockOrgDbNetz = {
id: 'db-netz',
type: 'organization' as const,
created: '2024-01-01',
modified: '2024-06-01',
name: 'DB Netz AG',
industry: 'Transport & Infrastruktur',
};
// --- Setup ---
function setupMocks() {
mockReadEntity.mockImplementation(async (type, id) => {
if (type === 'person' && id === 'andre-knie') return mockPerson as any;
if (type === 'experience' && id === 'db-infrago-digitalisierung') return mockExperienceInfraGO as any;
if (type === 'experience' && id === 'data-hive-cassel-gruender') return mockExperienceDataHive as any;
if (type === 'experience' && id === 'db-netz-einfachbahn-jobsharing') return mockExperienceJobSharing as any;
if (type === 'organization' && id === 'db-infrago') return mockOrgInfraGO as any;
if (type === 'organization' && id === 'data-hive-cassel') return mockOrgDataHive as any;
if (type === 'organization' && id === 'db-netz') return mockOrgDbNetz as any;
throw new Error(`Entity not found: ${type} "${id}"`);
});
}
// --- Tests ---
describe('generateExperiences', () => {
beforeEach(() => {
vi.clearAllMocks();
setupMocks();
});
describe('Korrekte Zuordnung von Experiences aus KB', () => {
it('liest Person-Entity und zugehörige Experiences aus KB', async () => {
const results = await generateExperiences({ personId: 'andre-knie' });
expect(mockReadEntity).toHaveBeenCalledWith('person', 'andre-knie');
expect(mockReadEntity).toHaveBeenCalledWith('experience', 'db-infrago-digitalisierung');
expect(mockReadEntity).toHaveBeenCalledWith('experience', 'data-hive-cassel-gruender');
expect(mockReadEntity).toHaveBeenCalledWith('experience', 'db-netz-einfachbahn-jobsharing');
});
it('generiert Ergebnisse für alle Experiences der Person', async () => {
const results = await generateExperiences({ personId: 'andre-knie' });
expect(results).toHaveLength(3);
const ids = results.map((r) => r.experienceId);
expect(ids).toContain('db-infrago-digitalisierung');
expect(ids).toContain('data-hive-cassel-gruender');
expect(ids).toContain('db-netz-einfachbahn-jobsharing');
});
it('generiert nur angeforderte Experiences wenn experienceIds angegeben', async () => {
const results = await generateExperiences({
personId: 'andre-knie',
experienceIds: ['db-infrago-digitalisierung'],
});
expect(results).toHaveLength(1);
expect(results[0].experienceId).toBe('db-infrago-digitalisierung');
});
it('löst Organisationsnamen korrekt auf', async () => {
const results = await generateExperiences({ personId: 'andre-knie' });
const infrago = results.find((r) => r.experienceId === 'db-infrago-digitalisierung');
expect(infrago?.organization).toBe('DB InfraGO');
const dataHive = results.find((r) => r.experienceId === 'data-hive-cassel-gruender');
expect(dataHive?.organization).toBe('Data Hive Cassel');
});
it('überspringt Experiences die nicht geladen werden können', async () => {
mockReadEntity.mockImplementation(async (type, id) => {
if (type === 'person' && id === 'andre-knie') return mockPerson as any;
if (type === 'experience' && id === 'db-infrago-digitalisierung') return mockExperienceInfraGO as any;
if (type === 'experience' && id === 'data-hive-cassel-gruender') throw new Error('Not found');
if (type === 'experience' && id === 'db-netz-einfachbahn-jobsharing') return mockExperienceJobSharing as any;
if (type === 'organization' && id === 'db-infrago') return mockOrgInfraGO as any;
if (type === 'organization' && id === 'db-netz') return mockOrgDbNetz as any;
throw new Error(`Entity not found: ${type} "${id}"`);
});
const results = await generateExperiences({ personId: 'andre-knie' });
expect(results).toHaveLength(2);
expect(results.map((r) => r.experienceId)).not.toContain('data-hive-cassel-gruender');
});
});
describe('Keyword-Einbindung', () => {
it('enthält relevante Keywords in jedem Ergebnis', async () => {
const results = await generateExperiences({ personId: 'andre-knie' });
for (const result of results) {
expect(result.keywords).toBeDefined();
expect(Array.isArray(result.keywords)).toBe(true);
expect(result.keywords.length).toBeGreaterThan(0);
}
});
it('integriert angeforderte Keywords in die Ergebnisse', async () => {
const requestedKeywords = ['Digitalisierung', 'Innovation'];
const results = await generateExperiences({
personId: 'andre-knie',
keywords: requestedKeywords,
});
for (const result of results) {
for (const kw of requestedKeywords) {
expect(result.keywords).toContain(kw);
}
}
});
it('DB InfraGO Experience enthält Digitalisierungs-Keywords', async () => {
const results = await generateExperiences({
personId: 'andre-knie',
experienceIds: ['db-infrago-digitalisierung'],
});
const infrago = results[0];
const hasDigitalKeyword = infrago.keywords.some(
(kw) => kw.includes('Digital') || kw.includes('Transformation'),
);
expect(hasDigitalKeyword).toBe(true);
});
it('Data Hive Experience enthält KI-Keywords', async () => {
const results = await generateExperiences({
personId: 'andre-knie',
experienceIds: ['data-hive-cassel-gruender'],
});
const dataHive = results[0];
const hasKIKeyword = dataHive.keywords.some(
(kw) => kw.includes('KI') || kw.includes('Künstliche Intelligenz'),
);
expect(hasKIKeyword).toBe(true);
});
});
describe('Ergebnis-Struktur', () => {
it('jedes Ergebnis hat experienceId, title, organization, description, achievements, keywords, validation', async () => {
const results = await generateExperiences({ personId: 'andre-knie' });
for (const result of results) {
expect(result).toHaveProperty('experienceId');
expect(result).toHaveProperty('title');
expect(result).toHaveProperty('organization');
expect(result).toHaveProperty('description');
expect(result).toHaveProperty('achievements');
expect(result).toHaveProperty('keywords');
expect(result).toHaveProperty('validation');
}
});
it('experienceId ist ein nicht-leerer String', async () => {
const results = await generateExperiences({ personId: 'andre-knie' });
for (const result of results) {
expect(typeof result.experienceId).toBe('string');
expect(result.experienceId.length).toBeGreaterThan(0);
}
});
it('title ist ein nicht-leerer String', async () => {
const results = await generateExperiences({ personId: 'andre-knie' });
for (const result of results) {
expect(typeof result.title).toBe('string');
expect(result.title.length).toBeGreaterThan(0);
}
});
it('description ist ein nicht-leerer String', async () => {
const results = await generateExperiences({ personId: 'andre-knie' });
for (const result of results) {
expect(typeof result.description).toBe('string');
expect(result.description.length).toBeGreaterThan(0);
}
});
it('achievements ist ein Array von Strings', async () => {
const results = await generateExperiences({ personId: 'andre-knie' });
for (const result of results) {
expect(Array.isArray(result.achievements)).toBe(true);
for (const achievement of result.achievements) {
expect(typeof achievement).toBe('string');
}
}
});
it('validation enthält valid, errors und warnings', async () => {
const results = await generateExperiences({ personId: 'andre-knie' });
for (const result of results) {
expect(result.validation).toHaveProperty('valid');
expect(result.validation).toHaveProperty('errors');
expect(result.validation).toHaveProperty('warnings');
expect(typeof result.validation.valid).toBe('boolean');
expect(Array.isArray(result.validation.errors)).toBe(true);
expect(Array.isArray(result.validation.warnings)).toBe(true);
}
});
});
describe('DB InfraGO — ergebnisorientierte Beschreibung', () => {
it('enthält ergebnisorientierte Formulierungen statt Aufgabenbeschreibungen', async () => {
const results = await generateExperiences({
personId: 'andre-knie',
experienceIds: ['db-infrago-digitalisierung'],
});
const infrago = results[0];
// Should contain result-oriented language
const hasResultOrientation =
infrago.description.includes('Transformation') ||
infrago.description.includes('Ergebnis') ||
infrago.description.includes('vorantreib') ||
infrago.description.includes('Wirkung') ||
infrago.description.includes('Kundennutzen');
expect(hasResultOrientation).toBe(true);
});
it('positioniert DB InfraGO als primäre Rolle', async () => {
const results = await generateExperiences({
personId: 'andre-knie',
experienceIds: ['db-infrago-digitalisierung'],
});
const infrago = results[0];
expect(infrago.title).toBe('Experte für Digitalisierung');
expect(infrago.organization).toBe('DB InfraGO');
});
});
describe('Data Hive — messbare Achievements', () => {
it('enthält messbare Erfolge (50+ Projekte)', async () => {
const results = await generateExperiences({
personId: 'andre-knie',
experienceIds: ['data-hive-cassel-gruender'],
});
const dataHive = results[0];
const allText = dataHive.description + ' ' + dataHive.achievements.join(' ');
expect(allText).toContain('50+');
});
it('enthält messbare Erfolge (15+ Kunden)', async () => {
const results = await generateExperiences({
personId: 'andre-knie',
experienceIds: ['data-hive-cassel-gruender'],
});
const dataHive = results[0];
const allText = dataHive.description + ' ' + dataHive.achievements.join(' ');
expect(allText).toContain('15+');
});
it('enthält messbare Erfolge (ROI > 2)', async () => {
const results = await generateExperiences({
personId: 'andre-knie',
experienceIds: ['data-hive-cassel-gruender'],
});
const dataHive = results[0];
const allText = dataHive.description + ' ' + dataHive.achievements.join(' ');
expect(allText).toContain('ROI');
});
});
describe('JobSharing — Führungsinnovation', () => {
it('positioniert JobSharing als Führungsinnovation', async () => {
const results = await generateExperiences({
personId: 'andre-knie',
experienceIds: ['db-netz-einfachbahn-jobsharing'],
});
const jobSharing = results[0];
const allText = jobSharing.description + ' ' + jobSharing.achievements.join(' ');
const hasLeadershipInnovation =
allText.includes('Führungsinnovation') ||
allText.includes('Shared Leadership') ||
allText.includes('JobSharing') ||
allText.includes('geteilte Führung');
expect(hasLeadershipInnovation).toBe(true);
});
it('enthält Achievements zum Thema Shared Leadership', async () => {
const results = await generateExperiences({
personId: 'andre-knie',
experienceIds: ['db-netz-einfachbahn-jobsharing'],
});
const jobSharing = results[0];
const hasSharedLeadership = jobSharing.achievements.some(
(a) => a.includes('Shared Leadership') || a.includes('Führung') || a.includes('Tandem'),
);
expect(hasSharedLeadership).toBe(true);
});
});
describe('Zeichenlimit-Einhaltung', () => {
it('alle Beschreibungen bleiben unter 2.000 Zeichen', async () => {
const results = await generateExperiences({ personId: 'andre-knie' });
for (const result of results) {
expect(result.description.length).toBeLessThanOrEqual(EXPERIENCE_CHAR_LIMIT);
}
});
it('Validierung ist valid für alle generierten Beschreibungen', async () => {
const results = await generateExperiences({ personId: 'andre-knie' });
for (const result of results) {
expect(result.validation.valid).toBe(true);
expect(result.validation.errors).toHaveLength(0);
}
});
});
});
@@ -0,0 +1,399 @@
/**
* LinkedIn Experience Generator
*
* Generates result-oriented LinkedIn experience descriptions from KB data.
* Reads person experiences, skills, and projects, then produces optimized
* descriptions with measurable achievements and relevant keywords.
*/
import type { Person, Experience, Organization, Skill, Project } from '../schemas/types';
import type { ExperienceOptions, ExperienceResult } from './types';
import { validateExperience, EXPERIENCE_CHAR_LIMIT } from './constraints';
import { readEntity } from '../io/entity-files';
// --- Internal Types ---
interface ExperienceData {
experience: Experience;
orgName: string;
skills: Skill[];
projects: Project[];
}
// --- Experience Positioning Strategies ---
/**
* Maps experience IDs to positioning strategies that generate
* result-oriented descriptions instead of task lists.
*/
const POSITIONING_STRATEGIES: Record<string, (data: ExperienceData, keywords: string[]) => PositionedContent> = {
'db-infrago-digitalisierung': positionDbInfraGo,
'data-hive-cassel-gruender': positionDataHive,
'db-netz-einfachbahn-jobsharing': positionJobSharing,
'db-netz-einfachbahn-leiter': positionEinfachbahnLeiter,
'uni-kassel-teilgruppenleiter': positionAcademicLeader,
'uni-kassel-doktorand': positionAcademicFoundation,
'uni-kassel-dozent-physik': positionAcademicTeaching,
'hochschule-fresenius-dozent': positionFreseniusDozent,
};
interface PositionedContent {
description: string;
achievements: string[];
keywords: string[];
}
// --- Public API ---
/**
* Generates LinkedIn experience descriptions from KB data.
*
* Reads the person's experiences, enriches them with skills and projects,
* and produces result-oriented descriptions validated against LinkedIn constraints.
*/
export async function generateExperiences(options: ExperienceOptions): Promise<ExperienceResult[]> {
const { personId, experienceIds, keywords = [] } = options;
const person = (await readEntity('person', personId)) as Person;
const targetIds = experienceIds ?? person.experiences ?? [];
const results: ExperienceResult[] = [];
for (const expId of targetIds) {
try {
const data = await loadExperienceData(expId);
const positioned = generatePositionedContent(data, keywords);
const description = truncateToLimit(positioned.description);
const validation = validateExperience(description);
results.push({
experienceId: expId,
title: data.experience.title,
organization: data.orgName,
description,
keywords: positioned.keywords,
achievements: positioned.achievements,
validation,
});
} catch {
// Skip experiences that cannot be loaded
}
}
return results;
}
// --- Data Loading ---
async function loadExperienceData(expId: string): Promise<ExperienceData> {
const experience = (await readEntity('experience', expId)) as Experience;
let orgName = experience.organization;
try {
const org = (await readEntity('organization', experience.organization)) as Organization;
orgName = org.name;
} catch {
// Use raw org ID as fallback
}
const skills: Skill[] = [];
for (const skillId of experience.skillsUsed ?? []) {
try {
const skill = (await readEntity('skill', skillId)) as Skill;
skills.push(skill);
} catch {
// Skip missing skills
}
}
const projects: Project[] = [];
for (const projId of experience.projects ?? []) {
try {
const project = (await readEntity('project', projId)) as Project;
projects.push(project);
} catch {
// Skip missing projects
}
}
return { experience, orgName, skills, projects };
}
// --- Content Generation ---
function generatePositionedContent(data: ExperienceData, requestedKeywords: string[]): PositionedContent {
const strategy = POSITIONING_STRATEGIES[data.experience.id];
if (strategy) {
return strategy(data, requestedKeywords);
}
return generateDefaultContent(data, requestedKeywords);
}
// --- Positioning Strategies ---
function positionDbInfraGo(data: ExperienceData, requestedKeywords: string[]): PositionedContent {
const description = [
'Digitalen Kundennutzen schaffen — mit einem Blick für Technologie und Menschen.',
'',
'Als Experte für Digitalisierung bei DB InfraGO treibe ich die digitale Transformation der Schieneninfrastruktur voran. Mein Fokus: Projekte effizient zum Abschluss bringen und das System Schiene zukunftsfähig machen.',
'',
'Schwerpunkte:',
'→ Digitale Transformation großer Infrastrukturprojekte',
'→ Change Management an der Schnittstelle von IT und Fachbereichen',
'→ Teamführung mit Fokus auf Ergebnisse und Wirkung',
].join('\n');
const achievements = [
'Digitale Transformation der Schieneninfrastruktur vorangetrieben',
'Effiziente Projektsteuerung in komplexem Konzernumfeld',
];
const keywords = mergeKeywords(
['Digitalisierung', 'Digitale Transformation', 'Change Management', 'Infrastruktur', 'DB', 'Schienenverkehr'],
requestedKeywords,
);
return { description, achievements, keywords };
}
function positionDataHive(data: ExperienceData, requestedKeywords: string[]): PositionedContent {
const description = [
'Wir hassen Verschwendung und lieben Wandel.',
'',
'Als Gründer von Data Hive Cassel verbinde ich KI-Expertise mit echtem Veränderungswillen. Mein Anspruch: nicht nur technische Lösungen liefern, sondern nachhaltigen Wandel ermöglichen.',
'',
'Ergebnisse:',
'→ 50+ Projekte erfolgreich umgesetzt',
'→ 15+ Kunden aus Mittelstand und öffentlichem Sektor',
'→ ROI > 2 für Kunden durch datengetriebene Optimierung',
'→ KI praxistauglich gemacht — von der Strategie bis zur Implementierung',
'',
'Schwerpunkte: KI-Beratung, Prozessautomatisierung, Energieeffizienz, Change Management',
].join('\n');
const achievements = [
'50+ Projekte erfolgreich umgesetzt',
'15+ Kunden aus Mittelstand und öffentlichem Sektor',
'ROI > 2 für Kunden durch datengetriebene Optimierung',
'KI praxistauglich gemacht — von der Strategie bis zur Implementierung',
];
const keywords = mergeKeywords(
['Künstliche Intelligenz', 'KI-Beratung', 'Startup', 'Gründer', 'Prozessautomatisierung', 'Change Management', 'Energieeffizienz', 'ROI'],
requestedKeywords,
);
return { description, achievements, keywords };
}
function positionJobSharing(data: ExperienceData, requestedKeywords: string[]): PositionedContent {
const description = [
'Führung ist keine One-Man-Show — und das haben wir bewiesen.',
'',
'Im JobSharing-Tandem mit Claudia Froldi haben wir geteilte Führung auf Augenhöhe im Konzernumfeld etabliert. Ein bewusstes Experiment: Gehaltsverzicht für ein innovatives Führungsmodell.',
'',
'Ergebnisse:',
'→ Shared Leadership als Führungsinnovation im DB-Konzern etabliert',
'→ Komplementäre Stärken: Fachliche Führung + disziplinarische Führung',
'→ Team #Einfachbahn erfolgreich durch Transformation begleitet',
'→ Vorbild für neue Arbeitsmodelle in der Bahnbranche',
].join('\n');
const achievements = [
'Shared Leadership als Führungsinnovation im DB-Konzern etabliert',
'Bewusster Gehaltsverzicht für geteilte Führung auf Augenhöhe',
'Team erfolgreich durch Transformation begleitet',
];
const keywords = mergeKeywords(
['Shared Leadership', 'JobSharing', 'Führungsinnovation', 'New Work', 'Tandem-Führung', 'Transformation'],
requestedKeywords,
);
return { description, achievements, keywords };
}
function positionEinfachbahnLeiter(data: ExperienceData, requestedKeywords: string[]): PositionedContent {
const description = [
'Die Digitalisierung auf die Schiene bringen — eines der ambitioniertesten Vorhaben im Konzernumfeld.',
'',
'Als Leiter #Einfachbahn habe ich ein interdisziplinäres Team aufgebaut und zu Höchstleistungen geführt. Agile Methoden und innovative Arbeitsformen etabliert, Silos aufgebrochen.',
'',
'Ergebnisse:',
'→ Interdisziplinäres Team aufgebaut und agile Methoden etabliert',
'→ Kultur des offenen Lernens geschaffen',
'→ Technische und kulturelle Transformation nachhaltig vorangetrieben',
].join('\n');
const achievements = data.experience.achievements ?? [
'Interdisziplinäres Team aufgebaut und agile Methoden etabliert',
'Silos aufgebrochen und Kultur des offenen Lernens geschaffen',
];
const keywords = mergeKeywords(
['Digitalisierung', 'Agile Führung', 'Transformation', 'Teamaufbau', 'Change Management'],
requestedKeywords,
);
return { description, achievements, keywords };
}
function positionAcademicLeader(data: ExperienceData, requestedKeywords: string[]): PositionedContent {
const description = [
'Vom Labor in die Praxis — hier wurde das Fundament für meine KI-Expertise gelegt.',
'',
'Als Teilgruppenleiter Spektroskopie habe ich Forschung, Teamführung und Technologieentwicklung vereint. Machine Learning seit 2012 in der Praxis angewandt — lange bevor es Mainstream wurde.',
'',
'Ergebnisse:',
'→ 50+ wissenschaftliche Veröffentlichungen, 2.000+ Zitationen',
'→ 12 Mio. € Drittmittel eingeworben (DFG, BMBF, EU)',
'→ Internationale Forschungsteams aufgebaut und geführt',
'→ ML/PyTorch seit 2012 für Datenanalyse eingesetzt',
].join('\n');
const achievements = [
'50+ wissenschaftliche Veröffentlichungen, 2.000+ Zitationen',
'12 Mio. € Drittmittel eingeworben (DFG, BMBF, EU)',
'Internationale Forschungsteams aufgebaut und geführt',
'ML/PyTorch seit 2012 für Datenanalyse eingesetzt',
];
const keywords = mergeKeywords(
['Machine Learning', 'Forschung', 'Künstliche Intelligenz', 'Teamführung', 'Drittmittel', 'Publikationen'],
requestedKeywords,
);
return { description, achievements, keywords };
}
function positionAcademicFoundation(data: ExperienceData, requestedKeywords: string[]): PositionedContent {
const description = [
'Wissenschaftliche Exzellenz als Grundlage für praxisnahe KI-Anwendungen.',
'',
'Promotion in Atom- und Molekülphysik mit Fokus auf experimentelle Methoden und Datenanalyse. Die analytische Denkweise und der systematische Ansatz prägen bis heute meine Arbeit mit KI und Digitalisierung.',
'',
'Ergebnisse:',
'→ 10 wissenschaftliche Paper und 10 internationale Konferenzbeiträge',
'→ Elektronenstrahlquelle von Simulation bis Konstruktion aufgebaut',
'→ Grundstein für 13+ Jahre KI-Erfahrung gelegt',
].join('\n');
const achievements = [
'10 wissenschaftliche Paper und 10 internationale Konferenzbeiträge',
'Elektronenstrahlquelle von Simulation bis Konstruktion aufgebaut',
'Grundstein für 13+ Jahre KI-Erfahrung gelegt',
];
const keywords = mergeKeywords(
['Promotion', 'Physik', 'Forschung', 'Datenanalyse', 'Wissenschaft'],
requestedKeywords,
);
return { description, achievements, keywords };
}
function positionAcademicTeaching(data: ExperienceData, requestedKeywords: string[]): PositionedContent {
const description = [
'Komplexe Themen verständlich machen — eine Kompetenz, die von der Physik bis zur KI-Beratung trägt.',
'',
'Als Dozent für experimentelle Atom- und Molekülphysik habe ich gelernt, anspruchsvolle Inhalte so aufzubereiten, dass sie begeistern und verstanden werden.',
].join('\n');
const achievements = [
'Komplexe wissenschaftliche Inhalte verständlich vermittelt',
];
const keywords = mergeKeywords(
['Lehre', 'Wissensvermittlung', 'Physik', 'Universität'],
requestedKeywords,
);
return { description, achievements, keywords };
}
function positionFreseniusDozent(data: ExperienceData, requestedKeywords: string[]): PositionedContent {
const description = [
'Agilität, Change und Innovation erlebbar machen — nicht nur theoretisch, sondern am eigenen Leib.',
'',
'Als Dozent an der Hochschule Fresenius habe ich MBA-Studierenden gezeigt, warum Agilität, Change, Innovation und Führung zusammengehören. Praxisnah, interaktiv und mit echten Erfahrungen aus Konzern und Startup.',
].join('\n');
const achievements = [
'MBA-Studierende für Agilität und Innovation begeistert',
'Praxiswissen aus Konzern und Startup in die Lehre eingebracht',
];
const keywords = mergeKeywords(
['Agilität', 'Change Management', 'Innovation', 'Führung', 'Lehre', 'MBA'],
requestedKeywords,
);
return { description, achievements, keywords };
}
// --- Default Content Generation ---
function generateDefaultContent(data: ExperienceData, requestedKeywords: string[]): PositionedContent {
const description = data.experience.description?.trim() ?? data.experience.title;
const achievements = data.experience.achievements ?? [];
const keywords = mergeKeywords(
extractSkillKeywords(data.skills),
requestedKeywords,
);
return { description, achievements, keywords };
}
// --- Keyword Helpers ---
const SKILL_KEYWORD_MAP: Record<string, string> = {
'kuenstliche-intelligenz': 'Künstliche Intelligenz',
'change-management': 'Change Management',
'digitale-transformation': 'Digitale Transformation',
'shared-leadership': 'Shared Leadership',
'agilitaet': 'Agilität',
'coaching': 'Coaching',
'team-management': 'Teamführung',
'it-strategie': 'IT-Strategie',
};
function extractSkillKeywords(skills: Skill[]): string[] {
const keywords: string[] = [];
for (const skill of skills) {
const mapped = SKILL_KEYWORD_MAP[skill.id];
if (mapped) {
keywords.push(mapped);
} else {
keywords.push(skill.name);
}
}
return keywords;
}
function mergeKeywords(base: string[], requested: string[]): string[] {
const merged = new Set<string>(base);
for (const kw of requested) {
merged.add(kw);
}
return Array.from(merged);
}
// --- Constraint Helpers ---
function truncateToLimit(text: string): string {
if (text.length <= EXPERIENCE_CHAR_LIMIT) {
return text;
}
const truncated = text.slice(0, EXPERIENCE_CHAR_LIMIT - 1);
const lastNewline = truncated.lastIndexOf('\n');
if (lastNewline > EXPERIENCE_CHAR_LIMIT * 0.7) {
return truncated.slice(0, lastNewline);
}
const lastSpace = truncated.lastIndexOf(' ');
if (lastSpace > EXPERIENCE_CHAR_LIMIT * 0.7) {
return truncated.slice(0, lastSpace) + '…';
}
return truncated + '…';
}
@@ -0,0 +1,235 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { generateHeadlines } from './headline-generator';
import { HEADLINE_CHAR_LIMIT } from './constraints';
// Mock the entity-files module
vi.mock('../io/entity-files', () => ({
readEntity: vi.fn(),
listEntities: vi.fn(),
}));
import { readEntity } from '../io/entity-files';
const mockReadEntity = vi.mocked(readEntity);
// --- Test Data ---
const mockPerson = {
id: 'andre-knie',
type: 'person' as const,
created: '2024-01-01',
modified: '2024-06-01',
name: { first: 'Andre', last: 'Knie', display: 'Dr. Andre Knie' },
summary: 'Experte für KI, Digitalisierung und Change Management',
education: [
{ institution: 'Universität Kassel', degree: 'Dr. rer. nat.', field: 'Physik', start: '2010', end: '2014' },
],
experiences: ['db-infrago-digitalisierung', 'data-hive-cassel-gruender'],
skills: ['kuenstliche-intelligenz', 'change-management'],
};
const mockExperienceInfraGO = {
id: 'db-infrago-digitalisierung',
type: 'experience' as const,
created: '2024-01-01',
modified: '2024-06-01',
title: 'Experte für Digitalisierung',
organization: 'db-infrago',
start: '2023-01',
end: null,
skillsUsed: ['kuenstliche-intelligenz', 'change-management', 'digitale-transformation'],
};
const mockExperienceDataHive = {
id: 'data-hive-cassel-gruender',
type: 'experience' as const,
created: '2024-01-01',
modified: '2024-06-01',
title: 'Gründer & Geschäftsführer',
organization: 'data-hive-cassel',
start: '2021-01',
end: null,
skillsUsed: ['kuenstliche-intelligenz', 'agilitaet', 'coaching'],
};
const mockOrgInfraGO = {
id: 'db-infrago',
type: 'organization' as const,
created: '2024-01-01',
modified: '2024-06-01',
name: 'DB InfraGO',
industry: 'Transport & Infrastruktur',
};
const mockOrgDataHive = {
id: 'data-hive-cassel',
type: 'organization' as const,
created: '2024-01-01',
modified: '2024-06-01',
name: 'Data Hive Cassel',
industry: 'KI-Beratung',
};
// --- Setup ---
function setupMocks() {
mockReadEntity.mockImplementation(async (type, id) => {
if (type === 'person' && id === 'andre-knie') return mockPerson as any;
if (type === 'experience' && id === 'db-infrago-digitalisierung') return mockExperienceInfraGO as any;
if (type === 'experience' && id === 'data-hive-cassel-gruender') return mockExperienceDataHive as any;
if (type === 'organization' && id === 'db-infrago') return mockOrgInfraGO as any;
if (type === 'organization' && id === 'data-hive-cassel') return mockOrgDataHive as any;
throw new Error(`Entity not found: ${type} "${id}"`);
});
}
// --- Tests ---
describe('generateHeadlines', () => {
beforeEach(() => {
vi.clearAllMocks();
setupMocks();
});
describe('Rollenextraktion aus KB', () => {
it('enthält beide Rollen (DB InfraGO + Data Hive Cassel) in den Varianten', async () => {
const result = await generateHeadlines({ personId: 'andre-knie' });
// At least one variant should mention DB InfraGO
const hasInfraGO = result.variants.some(
(v) => v.text.includes('DB InfraGO') || v.roles.some((r) => r.includes('DB InfraGO'))
);
expect(hasInfraGO).toBe(true);
// At least one variant should mention Data Hive Cassel
const hasDataHive = result.variants.some(
(v) => v.text.includes('Data Hive Cassel') || v.roles.some((r) => r.includes('Data Hive Cassel'))
);
expect(hasDataHive).toBe(true);
});
it('extrahiert Rollen-Metadaten korrekt in HeadlineVariant.roles', async () => {
const result = await generateHeadlines({ personId: 'andre-knie' });
// Each variant that mentions an org should have it in roles
for (const variant of result.variants) {
if (variant.text.includes('DB InfraGO')) {
expect(variant.roles.some((r) => r.includes('DB InfraGO'))).toBe(true);
}
if (variant.text.includes('Data Hive Cassel')) {
expect(variant.roles.some((r) => r.includes('Data Hive Cassel'))).toBe(true);
}
}
});
it('liest Person-Entity und zugehörige Experiences aus KB', async () => {
await generateHeadlines({ personId: 'andre-knie' });
expect(mockReadEntity).toHaveBeenCalledWith('person', 'andre-knie');
expect(mockReadEntity).toHaveBeenCalledWith('experience', 'db-infrago-digitalisierung');
expect(mockReadEntity).toHaveBeenCalledWith('experience', 'data-hive-cassel-gruender');
});
});
describe('Zeichenlimit-Einhaltung', () => {
it('alle Varianten bleiben unter 220 Zeichen', async () => {
const result = await generateHeadlines({ personId: 'andre-knie' });
for (const variant of result.variants) {
expect(variant.charCount).toBeLessThanOrEqual(HEADLINE_CHAR_LIMIT);
expect(variant.text.length).toBeLessThanOrEqual(HEADLINE_CHAR_LIMIT);
}
});
it('charCount stimmt mit tatsächlicher Textlänge überein', async () => {
const result = await generateHeadlines({ personId: 'andre-knie' });
for (const variant of result.variants) {
expect(variant.charCount).toBe(variant.text.length);
}
});
it('Validierung ist valid wenn alle Varianten unter dem Limit sind', async () => {
const result = await generateHeadlines({ personId: 'andre-knie' });
expect(result.validation.valid).toBe(true);
expect(result.validation.errors).toHaveLength(0);
});
});
describe('Variantengenerierung', () => {
it('generiert standardmäßig 3 Varianten', async () => {
const result = await generateHeadlines({ personId: 'andre-knie' });
expect(result.variants).toHaveLength(3);
});
it('generiert konfigurierbare Anzahl an Varianten', async () => {
const result = await generateHeadlines({ personId: 'andre-knie', variants: 5 });
expect(result.variants).toHaveLength(5);
});
it('generiert 1 Variante wenn variants=1', async () => {
const result = await generateHeadlines({ personId: 'andre-knie', variants: 1 });
expect(result.variants).toHaveLength(1);
});
it('jede Variante hat nicht-leeren Text', async () => {
const result = await generateHeadlines({ personId: 'andre-knie' });
for (const variant of result.variants) {
expect(variant.text.length).toBeGreaterThan(0);
}
});
it('jede Variante enthält keywords Array', async () => {
const result = await generateHeadlines({ personId: 'andre-knie' });
for (const variant of result.variants) {
expect(Array.isArray(variant.keywords)).toBe(true);
}
});
});
describe('Emphasis-Modi', () => {
it('dual-role Modus generiert Headlines mit beiden Organisationen', async () => {
const result = await generateHeadlines({ personId: 'andre-knie', emphasis: 'dual-role' });
const allTexts = result.variants.map((v) => v.text).join(' ');
expect(allTexts).toContain('DB InfraGO');
expect(allTexts).toContain('Data Hive Cassel');
});
it('ai-expert Modus enthält KI-bezogene Begriffe', async () => {
const result = await generateHeadlines({ personId: 'andre-knie', emphasis: 'ai-expert' });
const allTexts = result.variants.map((v) => v.text).join(' ');
const hasKIReference = allTexts.includes('KI') || allTexts.includes('Künstliche Intelligenz');
expect(hasKIReference).toBe(true);
});
it('leadership Modus enthält Führungs-bezogene Begriffe', async () => {
const result = await generateHeadlines({ personId: 'andre-knie', emphasis: 'leadership' });
const allTexts = result.variants.map((v) => v.text).join(' ');
const hasLeadershipRef =
allTexts.includes('Führung') ||
allTexts.includes('Konzern') ||
allTexts.includes('Startup');
expect(hasLeadershipRef).toBe(true);
});
it('alle Emphasis-Modi halten das Zeichenlimit ein', async () => {
const modes: Array<'dual-role' | 'ai-expert' | 'leadership'> = ['dual-role', 'ai-expert', 'leadership'];
for (const emphasis of modes) {
const result = await generateHeadlines({ personId: 'andre-knie', emphasis });
for (const variant of result.variants) {
expect(variant.charCount).toBeLessThanOrEqual(HEADLINE_CHAR_LIMIT);
}
}
});
});
});
@@ -0,0 +1,236 @@
/**
* LinkedIn Headline Generator
*
* Generates optimized LinkedIn headline variants from KB data.
* Reads person profile and experiences, extracts roles and keywords,
* and produces multiple headline variants with constraint validation.
*/
import type { Person, Experience, Organization } from '../schemas/types';
import type { HeadlineOptions, HeadlineResult, HeadlineVariant } from './types';
import { validateHeadline, HEADLINE_CHAR_LIMIT } from './constraints';
import { readEntity, listEntities } from '../io/entity-files';
// --- Internal Types ---
interface RoleInfo {
title: string;
organization: string;
orgId: string;
}
interface ProfileData {
person: Person;
currentRoles: RoleInfo[];
keywords: string[];
}
// --- Headline Templates by Emphasis ---
type EmphasisType = NonNullable<HeadlineOptions['emphasis']>;
interface TemplateFunction {
(data: ProfileData): string;
}
const TEMPLATES: Record<EmphasisType, TemplateFunction[]> = {
'dual-role': [
(d) => `${d.currentRoles.map((r) => `${r.title} @ ${r.organization}`).join(' | ')}${getDifferentiator(d)}`,
(d) => `${getDifferentiator(d)} | ${d.currentRoles.map((r) => `${r.title}, ${r.organization}`).join(' & ')}`,
(d) => `${d.currentRoles[0]?.title} (${d.currentRoles[0]?.organization}) + ${d.currentRoles[1]?.title} (${d.currentRoles[1]?.organization}) | ${getTopKeywords(d, 2)}`,
],
'ai-expert': [
(d) => `KI-Experte & ${d.currentRoles[0]?.title} | ${d.currentRoles.map((r) => r.organization).join(' + ')} | ${getDifferentiator(d)}`,
(d) => `${getDifferentiator(d)} | KI, Digitalisierung & Führung | ${d.currentRoles.map((r) => r.organization).join(' + ')}`,
(d) => `Künstliche Intelligenz × Praxis | ${d.currentRoles.map((r) => `${r.title} @ ${r.organization}`).join(' | ')}`,
],
'leadership': [
(d) => `Führung an der Schnittstelle von Konzern & Startup | ${d.currentRoles.map((r) => r.organization).join(' + ')} | ${getDifferentiator(d)}`,
(d) => `${d.currentRoles[0]?.title} @ ${d.currentRoles[0]?.organization} | ${d.currentRoles[1]?.title} @ ${d.currentRoles[1]?.organization} | ${getDifferentiator(d)}`,
(d) => `Konzern trifft Startup: ${d.currentRoles.map((r) => `${r.title}, ${r.organization}`).join(' & ')} | ${getTopKeywords(d, 2)}`,
],
};
// --- Public API ---
/**
* Generates LinkedIn headline variants from KB data.
*
* Reads the person's profile and current experiences, extracts roles and keywords,
* and produces multiple headline variants validated against LinkedIn constraints.
*/
export async function generateHeadlines(options: HeadlineOptions): Promise<HeadlineResult> {
const { personId, emphasis = 'dual-role', variants = 3 } = options;
const profileData = await loadProfileData(personId);
const rawVariants = buildVariants(profileData, emphasis, variants);
// Validate all variants — report the worst-case validation
const validatedVariants: HeadlineVariant[] = [];
let worstValidation = validateHeadline('placeholder');
worstValidation = { valid: true, errors: [], warnings: [] };
for (const text of rawVariants) {
const trimmed = truncateToLimit(text);
const validation = validateHeadline(trimmed);
const variant = buildVariantMetadata(trimmed, profileData);
validatedVariants.push(variant);
if (!validation.valid) {
worstValidation = validation;
} else if (validation.warnings.length > 0 && worstValidation.valid) {
worstValidation = validation;
}
}
return {
variants: validatedVariants,
validation: worstValidation,
};
}
// --- Data Loading ---
async function loadProfileData(personId: string): Promise<ProfileData> {
const person = (await readEntity('person', personId)) as Person;
const experienceIds = person.experiences ?? [];
const experiences: Experience[] = [];
for (const expId of experienceIds) {
try {
const exp = (await readEntity('experience', expId)) as Experience;
experiences.push(exp);
} catch {
// Skip missing experiences
}
}
const currentExperiences = experiences.filter((e) => e.end === null);
const currentRoles: RoleInfo[] = [];
for (const exp of currentExperiences) {
let orgName = exp.organization;
try {
const org = (await readEntity('organization', exp.organization)) as Organization;
orgName = org.name;
} catch {
// Use raw org ID as fallback
}
currentRoles.push({
title: exp.title,
organization: orgName,
orgId: exp.organization,
});
}
const keywords = extractKeywords(person, currentExperiences);
return { person, currentRoles, keywords };
}
// --- Variant Building ---
function buildVariants(data: ProfileData, emphasis: EmphasisType, count: number): string[] {
const templates = TEMPLATES[emphasis];
const results: string[] = [];
for (let i = 0; i < count; i++) {
const templateIndex = i % templates.length;
const text = templates[templateIndex](data);
results.push(text);
}
return results;
}
function buildVariantMetadata(text: string, data: ProfileData): HeadlineVariant {
const roles = data.currentRoles
.filter((r) => text.includes(r.organization) || text.includes(r.title))
.map((r) => `${r.title} @ ${r.organization}`);
const keywords = data.keywords.filter((kw) => text.toLowerCase().includes(kw.toLowerCase()));
return {
text,
charCount: text.length,
keywords,
roles,
};
}
// --- Keyword Extraction ---
function extractKeywords(person: Person, experiences: Experience[]): string[] {
const keywords = new Set<string>();
// From person summary
const summaryKeywords = ['KI', 'Digitalisierung', 'Change', 'Innovation', 'Führung', 'Technologie'];
for (const kw of summaryKeywords) {
if (person.summary?.includes(kw)) {
keywords.add(kw);
}
}
// From experience skills
const skillKeywordMap: Record<string, string> = {
'kuenstliche-intelligenz': 'KI',
'change-management': 'Change Management',
'digitale-transformation': 'Digitalisierung',
'shared-leadership': 'Shared Leadership',
'agilitaet': 'Agilität',
'coaching': 'Coaching',
'team-management': 'Teamführung',
'it-strategie': 'IT-Strategie',
};
for (const exp of experiences) {
for (const skillId of exp.skillsUsed ?? []) {
if (skillKeywordMap[skillId]) {
keywords.add(skillKeywordMap[skillId]);
}
}
}
return Array.from(keywords);
}
// --- Differentiating Elements ---
function getDifferentiator(data: ProfileData): string {
const differentiators = [
'Physiker macht KI praxistauglich',
'Angst vor Technologie überwinden',
'Konzern × Startup',
'Wissenschaft trifft Wirtschaft',
'KI mit Haltung',
];
// Pick based on emphasis and available data
if (data.person.education?.some((e) => e.field?.includes('Physik'))) {
return differentiators[0];
}
if (data.currentRoles.length >= 2) {
return differentiators[2];
}
return differentiators[1];
}
function getTopKeywords(data: ProfileData, count: number): string {
return data.keywords.slice(0, count).join(' | ');
}
// --- Constraint Helpers ---
function truncateToLimit(text: string): string {
if (text.length <= HEADLINE_CHAR_LIMIT) {
return text;
}
// Truncate at last space before limit, add ellipsis
const truncated = text.slice(0, HEADLINE_CHAR_LIMIT - 1);
const lastSpace = truncated.lastIndexOf(' ');
if (lastSpace > HEADLINE_CHAR_LIMIT * 0.7) {
return truncated.slice(0, lastSpace) + '…';
}
return truncated + '…';
}
@@ -0,0 +1,779 @@
/**
* Integration Tests for LinkedIn Module
*
* Tests the end-to-end flows:
* - KB lesen → Profil-Generierung → Output schreiben
* - Content-Strategie-Generierung → Output schreiben
* - Tracking-Persistenz: addTrackingEntry → getWeeklyReport → getMonthlyTrend → generateTrackingReport
* - Constraint-Validierung im Gesamtfluss
*
* Requirements: 1.11.5, 2.12.7, 3.13.6, 8.18.7
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtemp, rm, readFile, mkdir, writeFile, readdir } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { generateProfileOutput } from './profile-output';
import { writeContentStrategyOutput } from './strategy-output';
import { addTrackingEntry, getWeeklyReport, getMonthlyTrend } from './tracking-manager';
import { generateTrackingReport } from './tracking-output';
import {
validateHeadline,
validateAbout,
validateExperience,
HEADLINE_CHAR_LIMIT,
ABOUT_CHAR_LIMIT,
EXPERIENCE_CHAR_LIMIT,
} from './constraints';
describe('LinkedIn Integration Tests', () => {
let tempDir: string;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), 'linkedin-integration-'));
// Set up KB structure
const profilesDir = join(tempDir, 'kb', 'profiles');
const experiencesDir = join(tempDir, 'kb', 'experiences');
const organizationsDir = join(tempDir, 'kb', 'organizations');
const projectsDir = join(tempDir, 'kb', 'projects');
const trackingDir = join(tempDir, 'kb', 'tracking');
await mkdir(profilesDir, { recursive: true });
await mkdir(experiencesDir, { recursive: true });
await mkdir(organizationsDir, { recursive: true });
await mkdir(projectsDir, { recursive: true });
await mkdir(trackingDir, { recursive: true });
// Person entity
await writeFile(
join(profilesDir, 'andre-knie.yaml'),
[
'id: andre-knie',
'type: person',
'created: 2024-01-01',
'modified: 2024-06-01',
'name:',
' display: Dr. Andre Knie',
' first: Andre',
' last: Knie',
' title: Dr.',
'summary: Experte für KI und Digitalisierung mit 13+ Jahren Erfahrung',
'experiences:',
' - db-infrago-digitalisierung',
' - data-hive-cassel-gruender',
' - db-netz-einfachbahn-jobsharing',
' - uni-kassel-doktorand',
'education:',
' - degree: Dr. rer. nat.',
' field: Physik',
' institution: Universität Kassel',
' year: 2017',
'publications: 60+',
'citations: 2000+',
].join('\n'),
'utf-8',
);
// Experience: DB InfraGO
await writeFile(
join(experiencesDir, 'db-infrago-digitalisierung.yaml'),
[
'id: db-infrago-digitalisierung',
'type: experience',
'created: 2024-01-01',
'modified: 2024-06-01',
'title: Experte für Digitalisierung',
'organization: db-infrago',
'start: 2023-07',
'end: null',
'description: Verantwortlich für die digitale Transformation der Instandhaltung',
'skills-used:',
' - digitale-transformation',
' - change-management',
' - kuenstliche-intelligenz',
'achievements:',
' - Digitalisierungsstrategie für 3 Fachbereiche entwickelt',
' - KI-Pilotprojekte initiiert',
].join('\n'),
'utf-8',
);
// Experience: Data Hive Cassel
await writeFile(
join(experiencesDir, 'data-hive-cassel-gruender.yaml'),
[
'id: data-hive-cassel-gruender',
'type: experience',
'created: 2024-01-01',
'modified: 2024-06-01',
'title: Gründer & Geschäftsführer',
'organization: data-hive-cassel',
'start: 2021-01',
'end: null',
'description: KI-Beratung und Implementierung für den Mittelstand',
'skills-used:',
' - kuenstliche-intelligenz',
' - unternehmensgruendung',
' - beratung',
'achievements:',
' - 50+ Projekte erfolgreich umgesetzt',
' - 15+ Kunden betreut',
' - ROI > 2 bei allen Projekten',
].join('\n'),
'utf-8',
);
// Experience: DB Netz JobSharing
await writeFile(
join(experiencesDir, 'db-netz-einfachbahn-jobsharing.yaml'),
[
'id: db-netz-einfachbahn-jobsharing',
'type: experience',
'created: 2024-01-01',
'modified: 2024-06-01',
'title: Leiter EinfachBahn (JobSharing)',
'organization: db-netz',
'start: 2020-01',
'end: 2023-06',
'description: Führung im JobSharing-Modell als Innovation in der Führungskultur',
'skills-used:',
' - fuehrung',
' - innovation',
' - change-management',
'achievements:',
' - Erstes JobSharing-Tandem auf Leitungsebene bei DB Netz',
' - Team von 25 Mitarbeitenden geführt',
].join('\n'),
'utf-8',
);
// Experience: Uni Kassel
await writeFile(
join(experiencesDir, 'uni-kassel-doktorand.yaml'),
[
'id: uni-kassel-doktorand',
'type: experience',
'created: 2024-01-01',
'modified: 2024-06-01',
'title: Wissenschaftlicher Mitarbeiter & Doktorand',
'organization: universitaet-kassel',
'start: 2012-04',
'end: 2017-12',
'description: Forschung im Bereich maschinelles Lernen und Physik',
'skills-used:',
' - maschinelles-lernen',
' - forschung',
' - lehre',
'achievements:',
' - Promotion mit Auszeichnung',
' - 60+ Publikationen',
' - 2000+ Zitationen',
].join('\n'),
'utf-8',
);
// Organization: DB InfraGO
await writeFile(
join(organizationsDir, 'db-infrago.yaml'),
[
'id: db-infrago',
'type: organization',
'created: 2024-01-01',
'modified: 2024-01-01',
'name: DB InfraGO AG',
'industry: Schieneninfrastruktur',
].join('\n'),
'utf-8',
);
// Organization: Data Hive Cassel
await writeFile(
join(organizationsDir, 'data-hive-cassel.yaml'),
[
'id: data-hive-cassel',
'type: organization',
'created: 2024-01-01',
'modified: 2024-01-01',
'name: Data Hive Cassel GmbH',
'industry: KI-Beratung',
].join('\n'),
'utf-8',
);
// Organization: DB Netz
await writeFile(
join(organizationsDir, 'db-netz.yaml'),
[
'id: db-netz',
'type: organization',
'created: 2024-01-01',
'modified: 2024-01-01',
'name: DB Netz AG',
'industry: Schieneninfrastruktur',
].join('\n'),
'utf-8',
);
// Organization: Universität Kassel
await writeFile(
join(organizationsDir, 'universitaet-kassel.yaml'),
[
'id: universitaet-kassel',
'type: organization',
'created: 2024-01-01',
'modified: 2024-01-01',
'name: Universität Kassel',
'industry: Hochschule',
].join('\n'),
'utf-8',
);
// Project: KI-Beratung
await writeFile(
join(projectsDir, 'ki-fraitag.yaml'),
[
'id: ki-fraitag',
'type: project',
'created: 2024-01-01',
'modified: 2024-01-01',
'name: KI-FrAItag',
'description: Regelmäßiges KI-Format für Wissenstransfer',
'organization: data-hive-cassel',
].join('\n'),
'utf-8',
);
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
describe('Profile Output — End-to-End Flow', () => {
it('reads KB data, generates all sections, and writes output file', async () => {
const result = await generateProfileOutput({
personId: 'andre-knie',
basePath: tempDir,
});
// Verify result structure
expect(result.outputPath).toContain('profile-optimized.md');
expect(result.generatedAt).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(result.sections.headlines.count).toBeGreaterThan(0);
expect(result.sections.about.charCount).toBeGreaterThan(0);
expect(result.sections.experiences.count).toBeGreaterThan(0);
// Verify file was actually written to disk
const content = await readFile(result.outputPath, 'utf-8');
expect(content.length).toBeGreaterThan(0);
expect(content).toContain('# LinkedIn-Profil — Optimiert');
expect(content).toContain('## Headline');
expect(content).toContain('## About Section');
expect(content).toContain('## Experience');
});
it('generates multiple headline variants within character limit', async () => {
const result = await generateProfileOutput({
personId: 'andre-knie',
headline: { variants: 3 },
basePath: tempDir,
});
const content = await readFile(result.outputPath, 'utf-8');
// All variants should be present
expect(content).toContain('### Variante 1');
expect(content).toContain('### Variante 2');
expect(content).toContain('### Variante 3');
expect(result.sections.headlines.count).toBe(3);
});
it('generates about section with hook and full text', async () => {
const result = await generateProfileOutput({
personId: 'andre-knie',
basePath: tempDir,
});
const content = await readFile(result.outputPath, 'utf-8');
expect(content).toContain('### Hook (sichtbar vor "mehr anzeigen")');
expect(content).toContain('### Volltext');
expect(content).toContain('/2.600 Zeichen');
});
it('generates experience sections for all KB experiences', async () => {
const result = await generateProfileOutput({
personId: 'andre-knie',
basePath: tempDir,
});
// Should have at least the 4 experiences from KB
expect(result.sections.experiences.count).toBeGreaterThanOrEqual(2);
const content = await readFile(result.outputPath, 'utf-8');
expect(content).toContain('## Experience');
});
it('includes validation summary in output', async () => {
const result = await generateProfileOutput({
personId: 'andre-knie',
basePath: tempDir,
});
const content = await readFile(result.outputPath, 'utf-8');
expect(content).toContain('## Validierung');
expect(content).toContain('| Sektion | Status | Details |');
});
});
describe('Constraint-Validierung im Gesamtfluss', () => {
it('all generated headlines pass constraint validation', async () => {
const result = await generateProfileOutput({
personId: 'andre-knie',
headline: { variants: 3 },
basePath: tempDir,
});
expect(result.sections.headlines.valid).toBe(true);
// Read the output and extract headline texts to validate independently
const content = await readFile(result.outputPath, 'utf-8');
const headlineMatches = content.match(/### Variante \d+\n\n(.+)\n/g);
if (headlineMatches) {
for (const match of headlineMatches) {
const text = match.split('\n\n')[1]?.trim() ?? '';
if (text.length > 0) {
const validation = validateHeadline(text);
expect(validation.valid).toBe(true);
expect(text.length).toBeLessThanOrEqual(HEADLINE_CHAR_LIMIT);
}
}
}
});
it('generated about section passes constraint validation', async () => {
const result = await generateProfileOutput({
personId: 'andre-knie',
basePath: tempDir,
});
expect(result.sections.about.valid).toBe(true);
expect(result.sections.about.charCount).toBeLessThanOrEqual(ABOUT_CHAR_LIMIT);
expect(result.sections.about.charCount).toBeGreaterThan(0);
});
it('all generated experiences pass constraint validation', async () => {
const result = await generateProfileOutput({
personId: 'andre-knie',
basePath: tempDir,
});
expect(result.sections.experiences.allValid).toBe(true);
});
it('overall validation status is valid or warnings (no errors)', async () => {
const result = await generateProfileOutput({
personId: 'andre-knie',
basePath: tempDir,
});
expect(['valid', 'warnings']).toContain(result.validationStatus);
});
});
describe('Content Strategy Output — End-to-End Flow', () => {
it('generates content strategy and writes output file', async () => {
const markdown = await writeContentStrategyOutput({
personId: 'andre-knie',
quarter: '2026-Q3',
basePath: tempDir,
});
// Verify markdown content
expect(markdown).toContain('# Content-Strategie LinkedIn — 2026-Q3');
expect(markdown).toContain('## Posting-Frequenz');
expect(markdown).toContain('## Themen-Cluster');
expect(markdown).toContain('## Wochenplan');
expect(markdown).toContain('## Engagement-Routine');
expect(markdown).toContain('## Format-Mix');
// Verify file was written to disk
const outputPath = join(tempDir, 'output', 'linkedin', 'content-strategy.md');
const fileContent = await readFile(outputPath, 'utf-8');
expect(fileContent).toBe(markdown);
});
it('includes all three theme clusters', async () => {
const markdown = await writeContentStrategyOutput({
personId: 'andre-knie',
quarter: '2026-Q3',
basePath: tempDir,
});
expect(markdown).toContain('Innovation');
expect(markdown).toContain('Mensch');
expect(markdown).toContain('Verantwortung');
});
it('includes YAML frontmatter with metadata', async () => {
const markdown = await writeContentStrategyOutput({
personId: 'andre-knie',
quarter: '2026-Q3',
basePath: tempDir,
});
expect(markdown).toContain('---');
expect(markdown).toContain('quarter: 2026-Q3');
expect(markdown).toContain('type: content-strategy');
});
it('passes existing formats to the generator', async () => {
const markdown = await writeContentStrategyOutput({
personId: 'andre-knie',
quarter: '2026-Q3',
existingFormats: ['podcast', 'column'],
basePath: tempDir,
});
// Should still produce valid output
expect(markdown).toContain('# Content-Strategie LinkedIn — 2026-Q3');
expect(markdown.length).toBeGreaterThan(100);
});
});
describe('Tracking-Persistenz — End-to-End Flow', () => {
it('addTrackingEntry persists data as YAML file', async () => {
await addTrackingEntry(
{
id: 'linkedin-2026-w28',
type: 'linkedin-tracking',
date: '2026-07-13',
metrics: {
profileViews: 142,
searchAppearances: 38,
connectionRequests: 12,
postImpressions: 4500,
engagementRate: 4.2,
comments: 23,
reposts: 8,
},
posts: [
{
title: 'KI in der Bahn — was wirklich funktioniert',
date: '2026-07-10',
format: 'text',
impressions: 2100,
engagementRate: 5.1,
comments: 14,
reposts: 5,
},
{
title: 'Carousel: 5 Fehler bei der KI-Einführung',
date: '2026-07-12',
format: 'carousel',
impressions: 2400,
engagementRate: 3.8,
comments: 9,
reposts: 3,
},
],
},
tempDir,
);
// Verify file was written
const trackingDir = join(tempDir, 'kb', 'tracking');
const files = await readdir(trackingDir);
expect(files).toContain('linkedin-2026-w28.yaml');
// Verify content is valid YAML with expected data
const content = await readFile(join(trackingDir, 'linkedin-2026-w28.yaml'), 'utf-8');
expect(content).toContain('linkedin-2026-w28');
expect(content).toContain('linkedin-tracking');
expect(content).toContain('profile-views');
expect(content).toContain('142');
});
it('addTrackingEntry marks best practices automatically', async () => {
await addTrackingEntry(
{
id: 'linkedin-2026-w28',
type: 'linkedin-tracking',
date: '2026-07-13',
metrics: {
profileViews: 100,
searchAppearances: 30,
connectionRequests: 10,
postImpressions: 3000,
engagementRate: 4.0,
comments: 20,
reposts: 6,
},
posts: [
{
title: 'High Engagement Post',
date: '2026-07-10',
format: 'text',
impressions: 2000,
engagementRate: 8.0,
comments: 15,
reposts: 5,
},
{
title: 'Low Engagement Post',
date: '2026-07-11',
format: 'carousel',
impressions: 1000,
engagementRate: 2.0,
comments: 3,
reposts: 1,
},
],
},
tempDir,
);
const content = await readFile(
join(tempDir, 'kb', 'tracking', 'linkedin-2026-w28.yaml'),
'utf-8',
);
// High engagement post should be marked as best practice
expect(content).toContain('is-best-practice: true');
expect(content).toContain('is-best-practice: false');
});
it('getWeeklyReport aggregates metrics from persisted entries', async () => {
// Add tracking entry first
await addTrackingEntry(
{
id: 'linkedin-2026-w28',
type: 'linkedin-tracking',
date: '2026-07-13',
metrics: {
profileViews: 142,
searchAppearances: 38,
connectionRequests: 12,
postImpressions: 4500,
engagementRate: 4.2,
comments: 23,
reposts: 8,
},
posts: [
{
title: 'Top Post',
date: '2026-07-10',
format: 'text',
impressions: 2100,
engagementRate: 5.1,
comments: 14,
reposts: 5,
},
],
},
tempDir,
);
// Get weekly report
const report = await getWeeklyReport('2026-07-13', tempDir);
expect(report.weekOf).toBe('2026-07-13');
expect(report.metrics.profileViews).toBe(142);
expect(report.metrics.postImpressions).toBe(4500);
expect(report.metrics.engagementRate).toBe(4.2);
expect(report.posts.length).toBe(1);
expect(report.topPost?.title).toBe('Top Post');
});
it('getMonthlyTrend calculates trends from persisted entries', async () => {
// Add two weeks of data
await addTrackingEntry(
{
id: 'linkedin-2026-w27',
type: 'linkedin-tracking',
date: '2026-07-06',
metrics: {
profileViews: 100,
searchAppearances: 30,
connectionRequests: 8,
postImpressions: 3000,
engagementRate: 3.5,
comments: 15,
reposts: 5,
},
posts: [
{
title: 'Week 27 Post',
date: '2026-07-06',
format: 'text',
impressions: 1500,
engagementRate: 3.5,
comments: 8,
reposts: 3,
},
],
},
tempDir,
);
await addTrackingEntry(
{
id: 'linkedin-2026-w28',
type: 'linkedin-tracking',
date: '2026-07-13',
metrics: {
profileViews: 142,
searchAppearances: 38,
connectionRequests: 12,
postImpressions: 4500,
engagementRate: 4.2,
comments: 23,
reposts: 8,
},
posts: [
{
title: 'Week 28 Post',
date: '2026-07-13',
format: 'carousel',
impressions: 2400,
engagementRate: 5.0,
comments: 12,
reposts: 6,
},
],
},
tempDir,
);
const trend = await getMonthlyTrend('2026-07', tempDir);
expect(trend.month).toBe('2026-07');
expect(trend.totalPosts).toBe(2);
expect(trend.averageMetrics.profileViews).toBeGreaterThan(0);
expect(trend.averageMetrics.postImpressions).toBeGreaterThan(0);
expect(trend.recommendations.length).toBeGreaterThan(0);
});
it('full tracking flow: add → weekly → monthly → report', async () => {
// Step 1: Add tracking entries
await addTrackingEntry(
{
id: 'linkedin-2026-w28',
type: 'linkedin-tracking',
date: '2026-07-13',
metrics: {
profileViews: 142,
searchAppearances: 38,
connectionRequests: 12,
postImpressions: 4500,
engagementRate: 4.2,
comments: 23,
reposts: 8,
},
posts: [
{
title: 'KI in der Bahn',
date: '2026-07-10',
format: 'text',
impressions: 2100,
engagementRate: 5.1,
comments: 14,
reposts: 5,
},
{
title: '5 Fehler bei KI-Einführung',
date: '2026-07-12',
format: 'carousel',
impressions: 2400,
engagementRate: 3.8,
comments: 9,
reposts: 3,
},
],
},
tempDir,
);
// Step 2: Verify weekly report
const weeklyReport = await getWeeklyReport('2026-07-13', tempDir);
expect(weeklyReport.metrics.profileViews).toBe(142);
expect(weeklyReport.posts.length).toBe(2);
// Step 3: Verify monthly trend
const monthlyTrend = await getMonthlyTrend('2026-07', tempDir);
expect(monthlyTrend.totalPosts).toBe(2);
expect(monthlyTrend.month).toBe('2026-07');
// Step 4: Generate tracking report
const outputDir = join(tempDir, 'output', 'linkedin');
const reportResult = await generateTrackingReport({
weekOf: '2026-07-13',
month: '2026-07',
basePath: tempDir,
outputDir,
});
expect(reportResult.hasWeeklyData).toBe(true);
expect(reportResult.hasMonthlyData).toBe(true);
expect(reportResult.markdown).toContain('# LinkedIn Tracking Report');
expect(reportResult.markdown).toContain('## Wöchentlicher Report');
expect(reportResult.markdown).toContain('## Monatliche Trendanalyse');
expect(reportResult.markdown).toContain('142');
expect(reportResult.markdown).toContain('KI in der Bahn');
// Verify file was written
const fileContent = await readFile(reportResult.filePath, 'utf-8');
expect(fileContent).toBe(reportResult.markdown);
});
it('week-over-week changes are calculated correctly', async () => {
// Previous week
await addTrackingEntry(
{
id: 'linkedin-2026-w27',
type: 'linkedin-tracking',
date: '2026-07-06',
metrics: {
profileViews: 100,
searchAppearances: 30,
connectionRequests: 8,
postImpressions: 3000,
engagementRate: 3.0,
comments: 15,
reposts: 5,
},
posts: [],
},
tempDir,
);
// Current week
await addTrackingEntry(
{
id: 'linkedin-2026-w28',
type: 'linkedin-tracking',
date: '2026-07-13',
metrics: {
profileViews: 150,
searchAppearances: 40,
connectionRequests: 12,
postImpressions: 4500,
engagementRate: 4.5,
comments: 25,
reposts: 10,
},
posts: [],
},
tempDir,
);
const report = await getWeeklyReport('2026-07-13', tempDir);
// 100 → 150 = +50%
expect(report.weekOverWeek.profileViewsChange).toBe(50);
// 3000 → 4500 = +50%
expect(report.weekOverWeek.impressionsChange).toBe(50);
// 3.0 → 4.5 = +50%
expect(report.weekOverWeek.engagementRateChange).toBe(50);
});
});
});
@@ -0,0 +1,227 @@
/**
* Unit tests for LinkedIn Profile Output Generator
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtemp, rm, readFile, mkdir, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { generateProfileOutput } from './profile-output';
describe('profile-output', () => {
let tempDir: string;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), 'profile-output-test-'));
// Set up minimal KB structure for testing
const profilesDir = join(tempDir, 'kb', 'profiles');
const experiencesDir = join(tempDir, 'kb', 'experiences');
const organizationsDir = join(tempDir, 'kb', 'organizations');
const projectsDir = join(tempDir, 'kb', 'projects');
await mkdir(profilesDir, { recursive: true });
await mkdir(experiencesDir, { recursive: true });
await mkdir(organizationsDir, { recursive: true });
await mkdir(projectsDir, { recursive: true });
// Person entity
await writeFile(
join(profilesDir, 'andre-knie.yaml'),
[
'id: andre-knie',
'type: person',
'created: 2024-01-01',
'modified: 2024-01-01',
'name:',
' display: Dr. Andre Knie',
' first: Andre',
' last: Knie',
' title: Dr.',
'summary: Experte für KI und Digitalisierung',
'experiences:',
' - db-infrago-digitalisierung',
' - data-hive-cassel-gruender',
'education:',
' - degree: Dr. rer. nat.',
' field: Physik',
' institution: Universität Kassel',
].join('\n'),
'utf-8',
);
// Experience: DB InfraGO
await writeFile(
join(experiencesDir, 'db-infrago-digitalisierung.yaml'),
[
'id: db-infrago-digitalisierung',
'type: experience',
'created: 2024-01-01',
'modified: 2024-01-01',
'title: Experte für Digitalisierung',
'organization: db-infrago',
'start: 2023-07',
'end: null',
'skills-used:',
' - digitale-transformation',
' - change-management',
].join('\n'),
'utf-8',
);
// Experience: Data Hive Cassel
await writeFile(
join(experiencesDir, 'data-hive-cassel-gruender.yaml'),
[
'id: data-hive-cassel-gruender',
'type: experience',
'created: 2024-01-01',
'modified: 2024-01-01',
'title: Gründer & Geschäftsführer',
'organization: data-hive-cassel',
'start: 2021-01',
'end: null',
'skills-used:',
' - kuenstliche-intelligenz',
].join('\n'),
'utf-8',
);
// Organization: DB InfraGO
await writeFile(
join(organizationsDir, 'db-infrago.yaml'),
[
'id: db-infrago',
'type: organization',
'created: 2024-01-01',
'modified: 2024-01-01',
'name: DB InfraGO AG',
].join('\n'),
'utf-8',
);
// Organization: Data Hive Cassel
await writeFile(
join(organizationsDir, 'data-hive-cassel.yaml'),
[
'id: data-hive-cassel',
'type: organization',
'created: 2024-01-01',
'modified: 2024-01-01',
'name: Data Hive Cassel GmbH',
].join('\n'),
'utf-8',
);
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
it('generates profile-optimized.md with all sections', async () => {
const result = await generateProfileOutput({
personId: 'andre-knie',
basePath: tempDir,
});
expect(result.outputPath).toContain('profile-optimized.md');
expect(result.generatedAt).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(result.sections.headlines.count).toBeGreaterThan(0);
expect(result.sections.about.charCount).toBeGreaterThan(0);
expect(result.sections.experiences.count).toBeGreaterThan(0);
// Verify file was written
const content = await readFile(result.outputPath, 'utf-8');
expect(content).toContain('# LinkedIn-Profil — Optimiert');
expect(content).toContain('Generated:');
expect(content).toContain('## Headline');
expect(content).toContain('## About Section');
expect(content).toContain('## Experience');
expect(content).toContain('## Validierung');
});
it('includes generation date in output', async () => {
const result = await generateProfileOutput({
personId: 'andre-knie',
basePath: tempDir,
});
const content = await readFile(result.outputPath, 'utf-8');
const today = new Date().toISOString().split('T')[0];
expect(content).toContain(`Generated: ${today}`);
});
it('includes validation status in output', async () => {
const result = await generateProfileOutput({
personId: 'andre-knie',
basePath: tempDir,
});
const content = await readFile(result.outputPath, 'utf-8');
expect(content).toContain('Validation:');
// Should have a validation summary table
expect(content).toContain('| Sektion | Status | Details |');
});
it('returns valid validation status when all constraints pass', async () => {
const result = await generateProfileOutput({
personId: 'andre-knie',
basePath: tempDir,
});
// All generated content should be within limits
expect(['valid', 'warnings']).toContain(result.validationStatus);
expect(result.sections.headlines.valid).toBe(true);
expect(result.sections.about.valid).toBe(true);
expect(result.sections.experiences.allValid).toBe(true);
});
it('creates output directory if it does not exist', async () => {
const result = await generateProfileOutput({
personId: 'andre-knie',
basePath: tempDir,
});
// File should exist at the expected path
const content = await readFile(result.outputPath, 'utf-8');
expect(content.length).toBeGreaterThan(0);
});
it('includes headline variants with character counts', async () => {
const result = await generateProfileOutput({
personId: 'andre-knie',
headline: { variants: 2 },
basePath: tempDir,
});
const content = await readFile(result.outputPath, 'utf-8');
expect(content).toContain('### Variante 1');
expect(content).toContain('### Variante 2');
expect(content).toContain('/220 Zeichen');
expect(result.sections.headlines.count).toBe(2);
});
it('includes about section with hook preview', async () => {
const result = await generateProfileOutput({
personId: 'andre-knie',
basePath: tempDir,
});
const content = await readFile(result.outputPath, 'utf-8');
expect(content).toContain('### Hook (sichtbar vor "mehr anzeigen")');
expect(content).toContain('### Volltext');
expect(content).toContain('/2.600 Zeichen');
});
it('includes experience sections with achievements', async () => {
const result = await generateProfileOutput({
personId: 'andre-knie',
basePath: tempDir,
});
const content = await readFile(result.outputPath, 'utf-8');
// Should contain experience entries
expect(content).toContain('## Experience');
expect(result.sections.experiences.count).toBeGreaterThanOrEqual(2);
});
});
+251
View File
@@ -0,0 +1,251 @@
/**
* LinkedIn Profile Output Generator
*
* Combines headline, about, and experience generators into a single
* Markdown document written to output/linkedin/profile-optimized.md.
* Includes generation date and validation status for each section.
*/
import { writeFile, mkdir } from 'node:fs/promises';
import { join } from 'node:path';
import { generateHeadlines } from './headline-generator';
import { generateAbout } from './about-generator';
import { generateExperiences } from './experience-generator';
import type { HeadlineOptions, AboutOptions, ExperienceOptions } from './types';
import type { ConstraintValidation } from './constraints';
// --- Public Interface ---
export interface ProfileOutputOptions {
/** Person entity ID */
personId: string;
/** Headline generation options */
headline?: Omit<HeadlineOptions, 'personId'>;
/** About section generation options */
about?: Omit<AboutOptions, 'personId'>;
/** Experience generation options */
experience?: Omit<ExperienceOptions, 'personId'>;
/** Base path for output directory (default: process.cwd()) */
basePath?: string;
}
export interface ProfileOutputResult {
/** Path to the written output file */
outputPath: string;
/** ISO date string of generation */
generatedAt: string;
/** Overall validation status */
validationStatus: 'valid' | 'warnings' | 'errors';
/** Section-level validation details */
sections: {
headlines: { count: number; valid: boolean };
about: { charCount: number; valid: boolean };
experiences: { count: number; allValid: boolean };
};
}
// --- Constants ---
const OUTPUT_DIR = 'output/linkedin';
const OUTPUT_FILE = 'profile-optimized.md';
// --- Public API ---
/**
* Generates the complete LinkedIn profile document and writes it to disk.
*
* Calls headline, about, and experience generators, assembles the results
* into a structured Markdown document, and writes to output/linkedin/profile-optimized.md.
*/
export async function generateProfileOutput(options: ProfileOutputOptions): Promise<ProfileOutputResult> {
const { personId, basePath = process.cwd() } = options;
const generatedAt = new Date().toISOString().split('T')[0];
// Generate all sections
const headlineResult = await generateHeadlines({
personId,
emphasis: options.headline?.emphasis,
variants: options.headline?.variants,
});
const aboutResult = await generateAbout({
personId,
tone: options.about?.tone,
includeStats: options.about?.includeStats,
});
const experienceResults = await generateExperiences({
personId,
experienceIds: options.experience?.experienceIds,
keywords: options.experience?.keywords,
});
// Determine overall validation status
const allValidations: ConstraintValidation[] = [
headlineResult.validation,
aboutResult.validation,
...experienceResults.map((e) => e.validation),
];
const hasErrors = allValidations.some((v) => !v.valid);
const hasWarnings = allValidations.some((v) => v.warnings.length > 0);
const validationStatus = hasErrors ? 'errors' : hasWarnings ? 'warnings' : 'valid';
// Assemble Markdown document
const markdown = assembleMarkdown({
generatedAt,
validationStatus,
headlineResult,
aboutResult,
experienceResults,
});
// Write to disk
const outputDir = join(basePath, OUTPUT_DIR);
await mkdir(outputDir, { recursive: true });
const outputPath = join(outputDir, OUTPUT_FILE);
await writeFile(outputPath, markdown, 'utf-8');
return {
outputPath,
generatedAt,
validationStatus,
sections: {
headlines: {
count: headlineResult.variants.length,
valid: headlineResult.validation.valid,
},
about: {
charCount: aboutResult.charCount,
valid: aboutResult.validation.valid,
},
experiences: {
count: experienceResults.length,
allValid: experienceResults.every((e) => e.validation.valid),
},
},
};
}
// --- Markdown Assembly ---
interface AssemblyInput {
generatedAt: string;
validationStatus: 'valid' | 'warnings' | 'errors';
headlineResult: Awaited<ReturnType<typeof generateHeadlines>>;
aboutResult: Awaited<ReturnType<typeof generateAbout>>;
experienceResults: Awaited<ReturnType<typeof generateExperiences>>;
}
function assembleMarkdown(input: AssemblyInput): string {
const { generatedAt, validationStatus, headlineResult, aboutResult, experienceResults } = input;
const sections: string[] = [];
// Header
sections.push(`# LinkedIn-Profil — Optimiert`);
sections.push('');
sections.push(`> Generated: ${generatedAt} | Validation: ${formatValidationStatus(validationStatus)}`);
sections.push('');
sections.push('---');
sections.push('');
// Headlines
sections.push('## Headline');
sections.push('');
for (let i = 0; i < headlineResult.variants.length; i++) {
const variant = headlineResult.variants[i];
sections.push(`### Variante ${i + 1}`);
sections.push('');
sections.push(variant.text);
sections.push('');
sections.push(`*${variant.charCount}/220 Zeichen | Keywords: ${variant.keywords.join(', ') || '—'}*`);
sections.push('');
}
sections.push('---');
sections.push('');
// About Section
sections.push('## About Section');
sections.push('');
sections.push(`*${aboutResult.charCount}/2.600 Zeichen | Validation: ${aboutResult.validation.valid ? '✅' : '❌'}*`);
sections.push('');
sections.push('### Hook (sichtbar vor "mehr anzeigen")');
sections.push('');
sections.push(aboutResult.hookPreview);
sections.push('');
sections.push('### Volltext');
sections.push('');
sections.push(aboutResult.fullText);
sections.push('');
sections.push('---');
sections.push('');
// Experience Sections
sections.push('## Experience');
sections.push('');
for (const exp of experienceResults) {
sections.push(`### ${exp.title}${exp.organization}`);
sections.push('');
sections.push(`*Validation: ${exp.validation.valid ? '✅' : '❌'} | Keywords: ${exp.keywords.slice(0, 5).join(', ')}*`);
sections.push('');
sections.push(exp.description);
sections.push('');
if (exp.achievements.length > 0) {
sections.push('**Achievements:**');
for (const achievement of exp.achievements) {
sections.push(`- ${achievement}`);
}
sections.push('');
}
}
sections.push('---');
sections.push('');
// Validation Summary
sections.push('## Validierung');
sections.push('');
sections.push(`| Sektion | Status | Details |`);
sections.push(`|---------|--------|---------|`);
sections.push(`| Headline | ${headlineResult.validation.valid ? '✅' : '❌'} | ${headlineResult.variants.length} Varianten generiert |`);
sections.push(`| About | ${aboutResult.validation.valid ? '✅' : '❌'} | ${aboutResult.charCount}/2.600 Zeichen |`);
sections.push(`| Experience | ${experienceResults.every((e) => e.validation.valid) ? '✅' : '❌'} | ${experienceResults.length} Positionen generiert |`);
sections.push('');
if (headlineResult.validation.warnings.length > 0 || aboutResult.validation.warnings.length > 0) {
sections.push('### Warnungen');
sections.push('');
for (const w of headlineResult.validation.warnings) {
sections.push(`- Headline: ${w.message}`);
}
for (const w of aboutResult.validation.warnings) {
sections.push(`- About: ${w.message}`);
}
for (const exp of experienceResults) {
for (const w of exp.validation.warnings) {
sections.push(`- Experience (${exp.title}): ${w.message}`);
}
}
sections.push('');
}
return sections.join('\n');
}
// --- Helpers ---
function formatValidationStatus(status: 'valid' | 'warnings' | 'errors'): string {
switch (status) {
case 'valid':
return '✅ Alle Constraints erfüllt';
case 'warnings':
return '⚠️ Warnungen vorhanden';
case 'errors':
return '❌ Constraint-Verletzungen';
}
}
@@ -0,0 +1,152 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { formatStrategyMarkdown, writeContentStrategyOutput } from './strategy-output';
import type { ContentStrategy } from './types';
// Mock fs and content-strategy module
vi.mock('node:fs/promises', () => ({
writeFile: vi.fn().mockResolvedValue(undefined),
mkdir: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('./content-strategy', () => ({
generateContentStrategy: vi.fn().mockResolvedValue({
postingFrequency: '2 Beiträge pro Woche',
themeCluster: [
{ name: 'Innovation & Technologie', weight: 40, topics: ['KI-Anwendungen', 'Digitalisierung'] },
{ name: 'Mensch & Kultur', weight: 35, topics: ['Change Management', 'Führung'] },
{ name: 'Verantwortung', weight: 25, topics: ['Ethik in der KI'] },
],
weeklyPlan: [
{ day: 'Dienstag', time: '08:00', format: 'Text', themeCluster: 'Innovation & Technologie' },
{ day: 'Donnerstag', time: '08:00', format: 'Carousel', themeCluster: 'Mensch & Kultur' },
],
engagementRoutine: {
commentsPerWeek: 5,
targetAccounts: ['KI-Thought-Leader DACH', 'Startup-Gründer'],
dailyTimeMinutes: 15,
},
formatMix: { text: 40, carousel: 30, video: 15, newsletter: 15 },
} satisfies ContentStrategy),
}));
describe('strategy-output', () => {
const mockStrategy: ContentStrategy = {
postingFrequency: '2 Beiträge pro Woche',
themeCluster: [
{ name: 'Innovation & Technologie', weight: 40, topics: ['KI-Anwendungen', 'Digitalisierung'] },
{ name: 'Mensch & Kultur', weight: 35, topics: ['Change Management', 'Führung'] },
{ name: 'Verantwortung', weight: 25, topics: ['Ethik in der KI'] },
],
weeklyPlan: [
{ day: 'Dienstag', time: '08:00', format: 'Text', themeCluster: 'Innovation & Technologie' },
{ day: 'Donnerstag', time: '08:00', format: 'Carousel', themeCluster: 'Mensch & Kultur' },
],
engagementRoutine: {
commentsPerWeek: 5,
targetAccounts: ['KI-Thought-Leader DACH', 'Startup-Gründer'],
dailyTimeMinutes: 15,
},
formatMix: { text: 40, carousel: 30, video: 15, newsletter: 15 },
};
describe('formatStrategyMarkdown', () => {
it('includes YAML frontmatter with quarter and generation date', () => {
const md = formatStrategyMarkdown(mockStrategy, '2026-Q3');
expect(md).toContain('---');
expect(md).toContain('quarter: 2026-Q3');
expect(md).toContain('type: content-strategy');
expect(md).toMatch(/generated: \d{4}-\d{2}-\d{2}/);
});
it('includes the quarter in the title', () => {
const md = formatStrategyMarkdown(mockStrategy, '2026-Q3');
expect(md).toContain('# Content-Strategie LinkedIn — 2026-Q3');
});
it('includes posting frequency section', () => {
const md = formatStrategyMarkdown(mockStrategy, '2026-Q3');
expect(md).toContain('## Posting-Frequenz');
expect(md).toContain('2 Beiträge pro Woche');
});
it('includes all theme clusters with weights and topics', () => {
const md = formatStrategyMarkdown(mockStrategy, '2026-Q3');
expect(md).toContain('### Innovation & Technologie (40%)');
expect(md).toContain('### Mensch & Kultur (35%)');
expect(md).toContain('### Verantwortung (25%)');
expect(md).toContain('- KI-Anwendungen');
expect(md).toContain('- Digitalisierung');
expect(md).toContain('- Change Management');
expect(md).toContain('- Ethik in der KI');
});
it('includes weekly plan as a table', () => {
const md = formatStrategyMarkdown(mockStrategy, '2026-Q3');
expect(md).toContain('## Wochenplan');
expect(md).toContain('| Tag | Uhrzeit | Format | Themen-Cluster |');
expect(md).toContain('| Dienstag | 08:00 | Text | Innovation & Technologie |');
expect(md).toContain('| Donnerstag | 08:00 | Carousel | Mensch & Kultur |');
});
it('includes engagement routine', () => {
const md = formatStrategyMarkdown(mockStrategy, '2026-Q3');
expect(md).toContain('## Engagement-Routine');
expect(md).toContain('5+');
expect(md).toContain('15 Minuten');
expect(md).toContain('- KI-Thought-Leader DACH');
expect(md).toContain('- Startup-Gründer');
});
it('includes format mix with percentages', () => {
const md = formatStrategyMarkdown(mockStrategy, '2026-Q3');
expect(md).toContain('## Format-Mix');
expect(md).toContain('- Text: 40%');
expect(md).toContain('- Carousel: 30%');
expect(md).toContain('- Video: 15%');
expect(md).toContain('- Newsletter: 15%');
});
});
describe('writeContentStrategyOutput', () => {
it('calls generateContentStrategy and writes output file', async () => {
const { writeFile, mkdir } = await import('node:fs/promises');
const { generateContentStrategy } = await import('./content-strategy');
const result = await writeContentStrategyOutput({
personId: 'andre-knie',
quarter: '2026-Q3',
basePath: '/test',
});
expect(generateContentStrategy).toHaveBeenCalledWith({
personId: 'andre-knie',
quarter: '2026-Q3',
existingFormats: undefined,
});
expect(mkdir).toHaveBeenCalled();
expect(writeFile).toHaveBeenCalledWith(
expect.stringContaining('content-strategy.md'),
expect.any(String),
'utf-8'
);
expect(result).toContain('# Content-Strategie LinkedIn — 2026-Q3');
});
it('passes existingFormats to the generator', async () => {
const { generateContentStrategy } = await import('./content-strategy');
await writeContentStrategyOutput({
personId: 'andre-knie',
quarter: '2026-Q3',
existingFormats: ['podcast', 'column'],
basePath: '/test',
});
expect(generateContentStrategy).toHaveBeenCalledWith({
personId: 'andre-knie',
quarter: '2026-Q3',
existingFormats: ['podcast', 'column'],
});
});
});
});
+132
View File
@@ -0,0 +1,132 @@
/**
* LinkedIn Content Strategy Output Generator
*
* Calls the content strategy generator and formats the result
* as a structured Markdown document written to output/linkedin/content-strategy.md.
* Includes generation date and quarter info.
*/
import { writeFile, mkdir } from 'node:fs/promises';
import { join } from 'node:path';
import { generateContentStrategy } from './content-strategy';
import type { ContentStrategy, ContentStrategyOptions, ThemeCluster, WeeklySlot } from './types';
export interface StrategyOutputOptions {
personId: string;
quarter: string;
existingFormats?: string[];
basePath?: string;
}
/**
* Generates the content strategy and writes it to output/linkedin/content-strategy.md.
* Returns the generated markdown string.
*/
export async function writeContentStrategyOutput(options: StrategyOutputOptions): Promise<string> {
const { personId, quarter, existingFormats, basePath = process.cwd() } = options;
const strategyOptions: ContentStrategyOptions = { personId, quarter, existingFormats };
const strategy = await generateContentStrategy(strategyOptions);
const markdown = formatStrategyMarkdown(strategy, quarter);
const outputDir = join(basePath, 'output', 'linkedin');
await mkdir(outputDir, { recursive: true });
const outputPath = join(outputDir, 'content-strategy.md');
await writeFile(outputPath, markdown, 'utf-8');
return markdown;
}
/**
* Formats a ContentStrategy into a structured Markdown document.
*/
export function formatStrategyMarkdown(strategy: ContentStrategy, quarter: string): string {
const generatedDate = new Date().toISOString().slice(0, 10);
const sections: string[] = [];
// Frontmatter
sections.push(
[
'---',
`generated: ${generatedDate}`,
`quarter: ${quarter}`,
'type: content-strategy',
'---',
].join('\n')
);
// Title
sections.push(`# Content-Strategie LinkedIn — ${quarter}\n\nGeneriert: ${generatedDate}`);
// Posting-Frequenz
sections.push(`## Posting-Frequenz\n\n${strategy.postingFrequency}`);
// Themen-Cluster
sections.push(formatThemeClusters(strategy.themeCluster));
// Wochenplan
sections.push(formatWeeklyPlan(strategy.weeklyPlan));
// Engagement-Routine
sections.push(formatEngagementRoutine(strategy.engagementRoutine));
// Format-Mix
sections.push(formatFormatMix(strategy.formatMix));
return sections.join('\n\n') + '\n';
}
function formatThemeClusters(clusters: ThemeCluster[]): string {
const lines: string[] = ['## Themen-Cluster'];
for (const cluster of clusters) {
lines.push('');
lines.push(`### ${cluster.name} (${cluster.weight}%)`);
lines.push('');
for (const topic of cluster.topics) {
lines.push(`- ${topic}`);
}
}
return lines.join('\n');
}
function formatWeeklyPlan(slots: WeeklySlot[]): string {
const lines: string[] = ['## Wochenplan'];
lines.push('');
lines.push('| Tag | Uhrzeit | Format | Themen-Cluster |');
lines.push('|-----|---------|--------|----------------|');
for (const slot of slots) {
lines.push(`| ${slot.day} | ${slot.time} | ${slot.format} | ${slot.themeCluster} |`);
}
return lines.join('\n');
}
function formatEngagementRoutine(routine: ContentStrategy['engagementRoutine']): string {
const lines: string[] = ['## Engagement-Routine'];
lines.push('');
lines.push(`- **Kommentare pro Woche:** ${routine.commentsPerWeek}+`);
lines.push(`- **Tägliche Investition:** ${routine.dailyTimeMinutes} Minuten`);
lines.push('');
lines.push('**Ziel-Accounts:**');
lines.push('');
for (const account of routine.targetAccounts) {
lines.push(`- ${account}`);
}
return lines.join('\n');
}
function formatFormatMix(mix: ContentStrategy['formatMix']): string {
const lines: string[] = ['## Format-Mix'];
lines.push('');
lines.push(`- Text: ${mix.text}%`);
lines.push(`- Carousel: ${mix.carousel}%`);
lines.push(`- Video: ${mix.video}%`);
lines.push(`- Newsletter: ${mix.newsletter}%`);
return lines.join('\n');
}
@@ -0,0 +1,269 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtemp, rm, readFile, mkdir, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { stringify, parse } from 'yaml';
import { addTrackingEntry, getWeeklyReport, getMonthlyTrend } from './tracking-manager';
import type { TrackingEntry } from './types';
// --- Test Helpers ---
let tempDir: string;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), 'tracking-test-'));
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
function makeEntry(overrides: Partial<TrackingEntry> = {}): TrackingEntry {
return {
id: 'linkedin-2026-w28',
type: 'linkedin-tracking',
date: '2026-07-13',
metrics: {
profileViews: 142,
searchAppearances: 38,
connectionRequests: 12,
postImpressions: 4500,
engagementRate: 4.2,
comments: 23,
reposts: 8,
},
posts: [
{
title: 'KI in der Bahn — was wirklich funktioniert',
date: '2026-07-10',
format: 'text',
impressions: 2100,
engagementRate: 5.1,
comments: 14,
reposts: 5,
},
{
title: 'Carousel: 5 Fehler bei der KI-Einführung',
date: '2026-07-12',
format: 'carousel',
impressions: 2400,
engagementRate: 3.8,
comments: 9,
reposts: 3,
},
],
...overrides,
};
}
// --- YAML Serialization/Deserialization ---
describe('YAML Serialisierung/Deserialisierung', () => {
it('writes a tracking entry as YAML and reads it back with correct data', async () => {
const entry = makeEntry();
await addTrackingEntry(entry, tempDir);
const filePath = join(tempDir, 'kb', 'tracking', `${entry.id}.yaml`);
const content = await readFile(filePath, 'utf-8');
const parsed = parse(content);
// YAML uses kebab-case keys
expect(parsed.id).toBe('linkedin-2026-w28');
expect(parsed.type).toBe('linkedin-tracking');
expect(parsed.date).toBe('2026-07-13');
expect(parsed.metrics['profile-views']).toBe(142);
expect(parsed.metrics['search-appearances']).toBe(38);
expect(parsed.metrics['connection-requests']).toBe(12);
expect(parsed.metrics['post-impressions']).toBe(4500);
expect(parsed.metrics['engagement-rate']).toBe(4.2);
});
it('preserves post data through serialization round-trip', async () => {
const entry = makeEntry();
await addTrackingEntry(entry, tempDir);
const filePath = join(tempDir, 'kb', 'tracking', `${entry.id}.yaml`);
const content = await readFile(filePath, 'utf-8');
const parsed = parse(content);
expect(parsed.posts).toHaveLength(2);
expect(parsed.posts[0].title).toBe('KI in der Bahn — was wirklich funktioniert');
expect(parsed.posts[0].format).toBe('text');
expect(parsed.posts[0].impressions).toBe(2100);
expect(parsed.posts[0]['engagement-rate']).toBe(5.1);
});
it('creates kb/tracking/ directory if it does not exist', async () => {
const entry = makeEntry();
await addTrackingEntry(entry, tempDir);
const filePath = join(tempDir, 'kb', 'tracking', `${entry.id}.yaml`);
const content = await readFile(filePath, 'utf-8');
expect(content).toBeTruthy();
});
it('handles entry without posts', async () => {
const entry = makeEntry({ posts: undefined });
await addTrackingEntry(entry, tempDir);
const filePath = join(tempDir, 'kb', 'tracking', `${entry.id}.yaml`);
const content = await readFile(filePath, 'utf-8');
const parsed = parse(content);
expect(parsed.posts).toBeUndefined();
expect(parsed.metrics['profile-views']).toBe(142);
});
});
// --- Weekly Report Aggregation ---
describe('Weekly-Report-Aggregation', () => {
it('aggregates metrics from multiple entries in a week', async () => {
const trackingDir = join(tempDir, 'kb', 'tracking');
await mkdir(trackingDir, { recursive: true });
// Two entries in the same week (week of 2026-07-13)
const entry1 = makeEntry({
id: 'linkedin-2026-w28-a',
date: '2026-07-13',
metrics: {
profileViews: 100,
searchAppearances: 20,
connectionRequests: 5,
postImpressions: 2000,
engagementRate: 4.0,
comments: 10,
reposts: 3,
},
posts: [],
});
const entry2 = makeEntry({
id: 'linkedin-2026-w28-b',
date: '2026-07-15',
metrics: {
profileViews: 50,
searchAppearances: 10,
connectionRequests: 3,
postImpressions: 1500,
engagementRate: 5.0,
comments: 8,
reposts: 2,
},
posts: [],
});
await addTrackingEntry(entry1, tempDir);
await addTrackingEntry(entry2, tempDir);
const report = await getWeeklyReport('2026-07-13', tempDir);
expect(report.weekOf).toBe('2026-07-13');
expect(report.metrics.profileViews).toBe(150); // 100 + 50
expect(report.metrics.searchAppearances).toBe(30); // 20 + 10
expect(report.metrics.connectionRequests).toBe(8); // 5 + 3
expect(report.metrics.postImpressions).toBe(3500); // 2000 + 1500
expect(report.metrics.comments).toBe(18); // 10 + 8
expect(report.metrics.reposts).toBe(5); // 3 + 2
// engagementRate is averaged
expect(report.metrics.engagementRate).toBe(4.5); // (4.0 + 5.0) / 2
});
it('identifies the top post by engagement rate', async () => {
const entry = makeEntry({
id: 'linkedin-2026-w28',
date: '2026-07-14',
posts: [
{
title: 'Low performer',
date: '2026-07-14',
format: 'text',
impressions: 500,
engagementRate: 2.0,
comments: 3,
reposts: 1,
},
{
title: 'Top performer',
date: '2026-07-15',
format: 'carousel',
impressions: 3000,
engagementRate: 7.5,
comments: 20,
reposts: 10,
},
],
});
await addTrackingEntry(entry, tempDir);
const report = await getWeeklyReport('2026-07-13', tempDir);
expect(report.topPost).toBeDefined();
expect(report.topPost!.title).toBe('Top performer');
expect(report.topPost!.engagementRate).toBe(7.5);
});
it('calculates week-over-week changes', async () => {
// Previous week entry
const prevEntry = makeEntry({
id: 'linkedin-2026-w27',
date: '2026-07-06',
metrics: {
profileViews: 100,
searchAppearances: 20,
connectionRequests: 5,
postImpressions: 2000,
engagementRate: 4.0,
comments: 10,
reposts: 3,
},
posts: [],
});
// Current week entry
const currEntry = makeEntry({
id: 'linkedin-2026-w28',
date: '2026-07-13',
metrics: {
profileViews: 150,
searchAppearances: 30,
connectionRequests: 8,
postImpressions: 3000,
engagementRate: 5.0,
comments: 15,
reposts: 5,
},
posts: [],
});
await addTrackingEntry(prevEntry, tempDir);
await addTrackingEntry(currEntry, tempDir);
const report = await getWeeklyReport('2026-07-13', tempDir);
// profileViews: (150 - 100) / 100 * 100 = 50%
expect(report.weekOverWeek.profileViewsChange).toBe(50);
// impressions: (3000 - 2000) / 2000 * 100 = 50%
expect(report.weekOverWeek.impressionsChange).toBe(50);
// engagementRate: (5.0 - 4.0) / 4.0 * 100 = 25%
expect(report.weekOverWeek.engagementRateChange).toBe(25);
});
it('returns zero changes when no previous week data exists', async () => {
const entry = makeEntry({
id: 'linkedin-2026-w28',
date: '2026-07-13',
posts: [],
});
await addTrackingEntry(entry, tempDir);
const report = await getWeeklyReport('2026-07-13', tempDir);
// No previous week → previous metrics are all 0
// calculateChange(0, current) → 100 when current > 0
expect(report.weekOverWeek.profileViewsChange).toBe(100);
expect(report.weekOverWeek.impressionsChange).toBe(100);
expect(report.weekOverWeek.engagementRateChange).toBe(100);
});
});
+384
View File
@@ -0,0 +1,384 @@
/**
* LinkedIn Tracking Manager
*
* Manages LinkedIn metrics as YAML entities in kb/tracking/.
* Provides functions to add tracking entries, generate weekly reports,
* and calculate monthly trends with best-practice marking and
* content optimization recommendations.
*/
import { readFile, writeFile, readdir, mkdir } from 'node:fs/promises';
import { join } from 'node:path';
import { stringify, parse } from 'yaml';
import type { TrackingEntry, PostMetric, WeeklyReport, MonthlyTrend } from './types';
// --- Key Mapping (camelCase ↔ kebab-case) ---
const TRACKING_CAMEL_TO_KEBAB: Record<string, string> = {
profileViews: 'profile-views',
searchAppearances: 'search-appearances',
connectionRequests: 'connection-requests',
postImpressions: 'post-impressions',
engagementRate: 'engagement-rate',
isBestPractice: 'is-best-practice',
};
const TRACKING_KEBAB_TO_CAMEL: Record<string, string> = Object.fromEntries(
Object.entries(TRACKING_CAMEL_TO_KEBAB).map(([camel, kebab]) => [kebab, camel])
);
// --- Key Conversion Helpers ---
function toKebabKeys(obj: unknown): unknown {
if (obj === null || obj === undefined) return obj;
if (Array.isArray(obj)) return obj.map(toKebabKeys);
if (typeof obj !== 'object') return obj;
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
const kebabKey = TRACKING_CAMEL_TO_KEBAB[key] ?? key;
result[kebabKey] = toKebabKeys(value);
}
return result;
}
function toCamelKeys(obj: unknown): unknown {
if (obj === null || obj === undefined) return obj;
if (Array.isArray(obj)) return obj.map(toCamelKeys);
if (typeof obj !== 'object') return obj;
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
const camelKey = TRACKING_KEBAB_TO_CAMEL[key] ?? key;
result[camelKey] = toCamelKeys(value);
}
return result;
}
// --- Serialization ---
function serializeTrackingEntry(entry: TrackingEntry): string {
const kebabObj = toKebabKeys(entry);
return stringify(kebabObj, { nullStr: 'null', lineWidth: 0 });
}
function deserializeTrackingEntry(yamlStr: string): TrackingEntry {
const parsed = parse(yamlStr);
return toCamelKeys(parsed) as TrackingEntry;
}
// --- Directory Helpers ---
function getTrackingDir(basePath: string = process.cwd()): string {
return join(basePath, 'kb', 'tracking');
}
function getTrackingFilePath(id: string, basePath: string = process.cwd()): string {
return join(getTrackingDir(basePath), `${id}.yaml`);
}
// --- Read All Entries ---
async function readAllEntries(basePath: string = process.cwd()): Promise<TrackingEntry[]> {
const dir = getTrackingDir(basePath);
let files: string[];
try {
files = await readdir(dir);
} catch (err: unknown) {
if (err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'ENOENT') {
return [];
}
throw err;
}
const yamlFiles = files.filter((f) => f.endsWith('.yaml'));
const entries: TrackingEntry[] = [];
for (const file of yamlFiles) {
const content = await readFile(join(dir, file), 'utf-8');
entries.push(deserializeTrackingEntry(content));
}
return entries;
}
// --- Best Practice Detection ---
/**
* Marks posts as best practice when their engagement rate exceeds
* the average engagement rate of all posts in the entry.
*/
function markBestPractices(entry: TrackingEntry): TrackingEntry {
if (!entry.posts || entry.posts.length === 0) return entry;
const avgEngagement =
entry.posts.reduce((sum, p) => sum + p.engagementRate, 0) / entry.posts.length;
const markedPosts = entry.posts.map((post) => ({
...post,
isBestPractice: post.engagementRate > avgEngagement,
}));
return { ...entry, posts: markedPosts };
}
// --- Public API ---
/**
* Adds a tracking entry to kb/tracking/ as a YAML file.
* Automatically marks posts as best practice when their engagement rate
* exceeds the average of all posts in the entry.
*/
export async function addTrackingEntry(
entry: TrackingEntry,
basePath: string = process.cwd()
): Promise<void> {
const dir = getTrackingDir(basePath);
await mkdir(dir, { recursive: true });
const markedEntry = markBestPractices(entry);
const yaml = serializeTrackingEntry(markedEntry);
const filePath = getTrackingFilePath(entry.id, basePath);
await writeFile(filePath, yaml, 'utf-8');
}
/**
* Generates a weekly report for the given week start date.
* Aggregates metrics and posts, identifies the top post,
* and calculates week-over-week changes.
*/
export async function getWeeklyReport(
weekOf: string,
basePath: string = process.cwd()
): Promise<WeeklyReport> {
const entries = await readAllEntries(basePath);
const weekStart = new Date(weekOf);
const weekEnd = new Date(weekStart);
weekEnd.setDate(weekEnd.getDate() + 7);
// Find entries within the target week
const weekEntries = entries.filter((e) => {
const d = new Date(e.date);
return d >= weekStart && d < weekEnd;
});
// Find entries from the previous week for comparison
const prevWeekStart = new Date(weekStart);
prevWeekStart.setDate(prevWeekStart.getDate() - 7);
const prevWeekEntries = entries.filter((e) => {
const d = new Date(e.date);
return d >= prevWeekStart && d < weekStart;
});
// Aggregate current week metrics
const metrics = aggregateMetrics(weekEntries);
const posts = weekEntries.flatMap((e) => e.posts ?? []);
const topPost = posts.length > 0
? posts.reduce((best, p) => (p.engagementRate > best.engagementRate ? p : best))
: undefined;
// Aggregate previous week metrics for comparison
const prevMetrics = aggregateMetrics(prevWeekEntries);
const weekOverWeek = {
profileViewsChange: calculateChange(prevMetrics.profileViews, metrics.profileViews),
impressionsChange: calculateChange(prevMetrics.postImpressions ?? 0, metrics.postImpressions ?? 0),
engagementRateChange: calculateChange(prevMetrics.engagementRate ?? 0, metrics.engagementRate ?? 0),
};
return { weekOf, metrics, posts, topPost, weekOverWeek };
}
/**
* Generates a monthly trend analysis for the given month.
* Calculates averages, identifies best practices, computes growth,
* and derives content optimization recommendations.
*/
export async function getMonthlyTrend(
month: string,
basePath: string = process.cwd()
): Promise<MonthlyTrend> {
const entries = await readAllEntries(basePath);
// Filter entries for the target month (format: "2026-07")
const monthEntries = entries.filter((e) => e.date.startsWith(month));
// Filter entries for the previous month
const prevMonth = getPreviousMonth(month);
const prevMonthEntries = entries.filter((e) => e.date.startsWith(prevMonth));
// Calculate averages for the month
const averageMetrics = calculateAverageMetrics(monthEntries);
const prevAverageMetrics = calculateAverageMetrics(prevMonthEntries);
// Collect all posts and identify best practices
const allPosts = monthEntries.flatMap((e) => e.posts ?? []);
const totalPosts = allPosts.length;
const bestPractices = allPosts.filter((p) => p.isBestPractice === true);
// Calculate growth percentages
const growth = {
profileViews: calculateChange(prevAverageMetrics.profileViews, averageMetrics.profileViews),
impressions: calculateChange(prevAverageMetrics.postImpressions, averageMetrics.postImpressions),
engagement: calculateChange(prevAverageMetrics.engagementRate, averageMetrics.engagementRate),
};
// Derive recommendations
const recommendations = deriveRecommendations(allPosts, averageMetrics, growth);
return {
month,
averageMetrics,
totalPosts,
bestPractices,
growth,
recommendations,
};
}
// --- Aggregation Helpers ---
function aggregateMetrics(entries: TrackingEntry[]): TrackingEntry['metrics'] {
if (entries.length === 0) {
return {
profileViews: 0,
searchAppearances: 0,
connectionRequests: 0,
postImpressions: 0,
engagementRate: 0,
comments: 0,
reposts: 0,
};
}
return {
profileViews: sum(entries, (e) => e.metrics.profileViews),
searchAppearances: sum(entries, (e) => e.metrics.searchAppearances),
connectionRequests: sum(entries, (e) => e.metrics.connectionRequests),
postImpressions: sum(entries, (e) => e.metrics.postImpressions ?? 0),
engagementRate: avg(entries, (e) => e.metrics.engagementRate ?? 0),
comments: sum(entries, (e) => e.metrics.comments ?? 0),
reposts: sum(entries, (e) => e.metrics.reposts ?? 0),
};
}
function calculateAverageMetrics(entries: TrackingEntry[]): MonthlyTrend['averageMetrics'] {
if (entries.length === 0) {
return { profileViews: 0, searchAppearances: 0, postImpressions: 0, engagementRate: 0 };
}
return {
profileViews: avg(entries, (e) => e.metrics.profileViews),
searchAppearances: avg(entries, (e) => e.metrics.searchAppearances),
postImpressions: avg(entries, (e) => e.metrics.postImpressions ?? 0),
engagementRate: avg(entries, (e) => e.metrics.engagementRate ?? 0),
};
}
// --- Trend & Change Calculation ---
function calculateChange(previous: number, current: number): number {
if (previous === 0) return current > 0 ? 100 : 0;
return Math.round(((current - previous) / previous) * 100);
}
function getPreviousMonth(month: string): string {
const [year, m] = month.split('-').map(Number);
if (m === 1) return `${year - 1}-12`;
return `${year}-${String(m - 1).padStart(2, '0')}`;
}
// --- Recommendations Engine ---
function deriveRecommendations(
posts: PostMetric[],
averageMetrics: MonthlyTrend['averageMetrics'],
growth: MonthlyTrend['growth']
): string[] {
const recommendations: string[] = [];
if (posts.length === 0) {
recommendations.push('Noch keine Posts im Zeitraum — regelmäßiges Posting aufbauen (Ziel: 2x/Woche).');
return recommendations;
}
// Analyze format performance
const formatPerformance = analyzeFormatPerformance(posts);
const bestFormat = formatPerformance.sort((a, b) => b.avgEngagement - a.avgEngagement)[0];
if (bestFormat) {
recommendations.push(
`Format "${bestFormat.format}" zeigt die höchste Engagement-Rate (${bestFormat.avgEngagement.toFixed(1)}%) — Anteil erhöhen.`
);
}
// Engagement trend
if (growth.engagement < 0) {
recommendations.push(
'Engagement-Rate rückläufig — mehr interaktive Formate (Fragen, Umfragen) einsetzen.'
);
} else if (growth.engagement > 20) {
recommendations.push(
'Starkes Engagement-Wachstum — aktuelle Content-Strategie beibehalten und ausbauen.'
);
}
// Profile visibility
if (growth.profileViews < 0) {
recommendations.push(
'Profilaufrufe rückläufig — Posting-Frequenz und Kommentar-Aktivität erhöhen.'
);
}
// Best practice analysis
const bestPractices = posts.filter((p) => p.isBestPractice);
if (bestPractices.length > 0) {
const bpFormats = [...new Set(bestPractices.map((p) => p.format))];
recommendations.push(
`Best-Practice-Posts nutzen bevorzugt: ${bpFormats.join(', ')} — diese Formate priorisieren.`
);
}
// Low engagement posts
const avgEngagement = posts.reduce((s, p) => s + p.engagementRate, 0) / posts.length;
const lowPerformers = posts.filter((p) => p.engagementRate < avgEngagement * 0.5);
if (lowPerformers.length > 0) {
recommendations.push(
`${lowPerformers.length} Post(s) mit unterdurchschnittlicher Performance — Themen und Timing überprüfen.`
);
}
return recommendations;
}
interface FormatPerformance {
format: string;
count: number;
avgEngagement: number;
}
function analyzeFormatPerformance(posts: PostMetric[]): FormatPerformance[] {
const byFormat = new Map<string, PostMetric[]>();
for (const post of posts) {
const existing = byFormat.get(post.format) ?? [];
existing.push(post);
byFormat.set(post.format, existing);
}
return Array.from(byFormat.entries()).map(([format, formatPosts]) => ({
format,
count: formatPosts.length,
avgEngagement: formatPosts.reduce((s, p) => s + p.engagementRate, 0) / formatPosts.length,
}));
}
// --- Math Helpers ---
function sum<T>(items: T[], getter: (item: T) => number): number {
return items.reduce((s, item) => s + getter(item), 0);
}
function avg<T>(items: T[], getter: (item: T) => number): number {
if (items.length === 0) return 0;
return sum(items, getter) / items.length;
}
@@ -0,0 +1,247 @@
/**
* Tests for LinkedIn Tracking Report Output
*
* Validates that tracking data is correctly formatted as Markdown
* and written to the expected output path.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtemp, mkdir, writeFile, readFile, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { stringify } from 'yaml';
import { generateTrackingReport } from './tracking-output';
describe('tracking-output', () => {
let tempDir: string;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), 'tracking-output-'));
await mkdir(join(tempDir, 'kb', 'tracking'), { recursive: true });
await mkdir(join(tempDir, 'output', 'linkedin'), { recursive: true });
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
function writeTrackingEntry(id: string, data: Record<string, unknown>) {
const filePath = join(tempDir, 'kb', 'tracking', `${id}.yaml`);
return writeFile(filePath, stringify(data), 'utf-8');
}
describe('generateTrackingReport', () => {
it('generates a weekly report with metrics and posts', async () => {
await writeTrackingEntry('linkedin-2026-w28', {
id: 'linkedin-2026-w28',
type: 'linkedin-tracking',
date: '2026-07-13',
metrics: {
'profile-views': 142,
'search-appearances': 38,
'connection-requests': 12,
'post-impressions': 4500,
'engagement-rate': 4.2,
comments: 23,
reposts: 8,
},
posts: [
{
title: 'KI in der Bahn',
date: '2026-07-10',
format: 'text',
impressions: 2100,
'engagement-rate': 5.1,
comments: 14,
reposts: 5,
'is-best-practice': true,
},
{
title: 'Carousel: 5 Fehler',
date: '2026-07-12',
format: 'carousel',
impressions: 2400,
'engagement-rate': 3.8,
comments: 9,
reposts: 3,
'is-best-practice': false,
},
],
});
const result = await generateTrackingReport({
weekOf: '2026-07-13',
basePath: tempDir,
outputDir: join(tempDir, 'output', 'linkedin'),
});
expect(result.hasWeeklyData).toBe(true);
expect(result.markdown).toContain('# LinkedIn Tracking Report');
expect(result.markdown).toContain('Generated:');
expect(result.markdown).toContain('## Wöchentlicher Report');
expect(result.markdown).toContain('Woche ab: 2026-07-13');
expect(result.markdown).toContain('142');
expect(result.markdown).toContain('4500');
expect(result.markdown).toContain('KI in der Bahn');
expect(result.markdown).toContain('Carousel: 5 Fehler');
expect(result.markdown).toContain('### Top-Beitrag');
});
it('generates a monthly trend report with recommendations', async () => {
await writeTrackingEntry('linkedin-2026-w28', {
id: 'linkedin-2026-w28',
type: 'linkedin-tracking',
date: '2026-07-13',
metrics: {
'profile-views': 142,
'search-appearances': 38,
'connection-requests': 12,
'post-impressions': 4500,
'engagement-rate': 4.2,
comments: 23,
reposts: 8,
},
posts: [
{
title: 'Top Post',
date: '2026-07-10',
format: 'text',
impressions: 2100,
'engagement-rate': 5.1,
comments: 14,
reposts: 5,
'is-best-practice': true,
},
],
});
const result = await generateTrackingReport({
month: '2026-07',
basePath: tempDir,
outputDir: join(tempDir, 'output', 'linkedin'),
});
expect(result.hasMonthlyData).toBe(true);
expect(result.markdown).toContain('## Monatliche Trendanalyse');
expect(result.markdown).toContain('Monat: 2026-07');
expect(result.markdown).toContain('### Durchschnittliche Metriken');
expect(result.markdown).toContain('### Empfehlungen');
});
it('generates both weekly and monthly sections when both options provided', async () => {
await writeTrackingEntry('linkedin-2026-w28', {
id: 'linkedin-2026-w28',
type: 'linkedin-tracking',
date: '2026-07-13',
metrics: {
'profile-views': 100,
'search-appearances': 20,
'connection-requests': 5,
'post-impressions': 2000,
'engagement-rate': 3.0,
comments: 10,
reposts: 4,
},
posts: [],
});
const result = await generateTrackingReport({
weekOf: '2026-07-13',
month: '2026-07',
basePath: tempDir,
outputDir: join(tempDir, 'output', 'linkedin'),
});
expect(result.hasWeeklyData).toBe(true);
expect(result.hasMonthlyData).toBe(true);
expect(result.markdown).toContain('## Wöchentlicher Report');
expect(result.markdown).toContain('## Monatliche Trendanalyse');
});
it('writes the report to the correct file path', async () => {
const result = await generateTrackingReport({
weekOf: '2026-07-13',
basePath: tempDir,
outputDir: join(tempDir, 'output', 'linkedin'),
});
const expectedPath = join(tempDir, 'output', 'linkedin', 'tracking-report.md');
expect(result.filePath).toBe(expectedPath);
const content = await readFile(expectedPath, 'utf-8');
expect(content).toBe(result.markdown);
});
it('handles empty tracking data gracefully', async () => {
const result = await generateTrackingReport({
weekOf: '2026-07-13',
month: '2026-07',
basePath: tempDir,
outputDir: join(tempDir, 'output', 'linkedin'),
});
expect(result.markdown).toContain('# LinkedIn Tracking Report');
// Weekly report with zero metrics
expect(result.markdown).toContain('## Wöchentlicher Report');
// Monthly with no posts
expect(result.markdown).toContain('Beiträge gesamt: 0');
});
it('includes week-over-week changes in weekly report', async () => {
// Previous week
await writeTrackingEntry('linkedin-2026-w27', {
id: 'linkedin-2026-w27',
type: 'linkedin-tracking',
date: '2026-07-06',
metrics: {
'profile-views': 100,
'search-appearances': 30,
'connection-requests': 8,
'post-impressions': 3000,
'engagement-rate': 3.0,
comments: 15,
reposts: 5,
},
posts: [],
});
// Current week
await writeTrackingEntry('linkedin-2026-w28', {
id: 'linkedin-2026-w28',
type: 'linkedin-tracking',
date: '2026-07-13',
metrics: {
'profile-views': 150,
'search-appearances': 40,
'connection-requests': 12,
'post-impressions': 4500,
'engagement-rate': 4.5,
comments: 25,
reposts: 10,
},
posts: [],
});
const result = await generateTrackingReport({
weekOf: '2026-07-13',
basePath: tempDir,
outputDir: join(tempDir, 'output', 'linkedin'),
});
// Should show positive changes
expect(result.markdown).toContain('+50%'); // profile views: 100 -> 150
expect(result.markdown).toContain('+50%'); // impressions: 3000 -> 4500
});
it('formats the change indicators correctly', async () => {
const result = await generateTrackingReport({
weekOf: '2026-07-13',
basePath: tempDir,
outputDir: join(tempDir, 'output', 'linkedin'),
});
// With no previous data, changes should be 0
expect(result.markdown).toContain('±0%');
});
});
});
+227
View File
@@ -0,0 +1,227 @@
/**
* LinkedIn Tracking Report Output
*
* Generates structured Markdown reports from tracking data.
* Combines weekly and monthly reports into output/linkedin/tracking-report.md.
*
* Requirements: 8.18.7
*/
import { writeFile, mkdir } from 'node:fs/promises';
import { join } from 'node:path';
import { getWeeklyReport, getMonthlyTrend } from './tracking-manager';
import type { WeeklyReport, MonthlyTrend, PostMetric } from './types';
// --- Public API ---
export interface TrackingReportOptions {
/** ISO date string for the week start (e.g., "2026-07-07") */
weekOf?: string;
/** Month identifier (e.g., "2026-07") */
month?: string;
/** Base path for KB data (defaults to process.cwd()) */
basePath?: string;
/** Output directory (defaults to output/linkedin) */
outputDir?: string;
}
export interface TrackingReportResult {
/** The generated Markdown content */
markdown: string;
/** Path where the report was written */
filePath: string;
/** Whether weekly data was included */
hasWeeklyData: boolean;
/** Whether monthly data was included */
hasMonthlyData: boolean;
}
/**
* Generates a tracking report and writes it to output/linkedin/tracking-report.md.
* Includes weekly and/or monthly sections depending on provided options.
*/
export async function generateTrackingReport(
options: TrackingReportOptions = {}
): Promise<TrackingReportResult> {
const basePath = options.basePath ?? process.cwd();
const outputDir = options.outputDir ?? join(basePath, 'output', 'linkedin');
let weeklyReport: WeeklyReport | undefined;
let monthlyTrend: MonthlyTrend | undefined;
if (options.weekOf) {
weeklyReport = await getWeeklyReport(options.weekOf, basePath);
}
if (options.month) {
monthlyTrend = await getMonthlyTrend(options.month, basePath);
}
// If neither specified, default to current week and month
if (!options.weekOf && !options.month) {
const now = new Date();
const weekStart = getWeekStart(now);
const month = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
weeklyReport = await getWeeklyReport(weekStart, basePath);
monthlyTrend = await getMonthlyTrend(month, basePath);
}
const markdown = formatTrackingReport(weeklyReport, monthlyTrend);
await mkdir(outputDir, { recursive: true });
const filePath = join(outputDir, 'tracking-report.md');
await writeFile(filePath, markdown, 'utf-8');
return {
markdown,
filePath,
hasWeeklyData: weeklyReport !== undefined,
hasMonthlyData: monthlyTrend !== undefined,
};
}
// --- Markdown Formatting ---
function formatTrackingReport(
weekly?: WeeklyReport,
monthly?: MonthlyTrend
): string {
const lines: string[] = [];
const generatedDate = new Date().toISOString().split('T')[0];
lines.push('# LinkedIn Tracking Report');
lines.push(`Generated: ${generatedDate}`);
lines.push('');
if (weekly) {
lines.push(...formatWeeklySection(weekly));
lines.push('');
}
if (monthly) {
lines.push(...formatMonthlySection(monthly));
lines.push('');
}
if (!weekly && !monthly) {
lines.push('> Keine Tracking-Daten vorhanden. Bitte zuerst Metriken über den Tracking Manager erfassen.');
lines.push('');
}
return lines.join('\n');
}
function formatWeeklySection(report: WeeklyReport): string[] {
const lines: string[] = [];
lines.push('## Wöchentlicher Report');
lines.push(`Woche ab: ${report.weekOf}`);
lines.push('');
// Metrics table
lines.push('### Profil-Metriken');
lines.push('');
lines.push('| Metrik | Wert | Veränderung |');
lines.push('|--------|------|-------------|');
lines.push(`| Profilaufrufe | ${report.metrics.profileViews} | ${formatChange(report.weekOverWeek.profileViewsChange)} |`);
lines.push(`| Suchergebnisse | ${report.metrics.searchAppearances} | — |`);
lines.push(`| Vernetzungsanfragen | ${report.metrics.connectionRequests} | — |`);
lines.push(`| Impressionen | ${report.metrics.postImpressions ?? 0} | ${formatChange(report.weekOverWeek.impressionsChange)} |`);
lines.push(`| Engagement-Rate | ${(report.metrics.engagementRate ?? 0).toFixed(1)}% | ${formatChange(report.weekOverWeek.engagementRateChange)} |`);
lines.push(`| Kommentare | ${report.metrics.comments ?? 0} | — |`);
lines.push(`| Reposts | ${report.metrics.reposts ?? 0} | — |`);
lines.push('');
// Posts
if (report.posts.length > 0) {
lines.push('### Beiträge dieser Woche');
lines.push('');
lines.push('| Titel | Format | Impressionen | Engagement | Best Practice |');
lines.push('|-------|--------|--------------|------------|---------------|');
for (const post of report.posts) {
lines.push(
`| ${post.title} | ${post.format} | ${post.impressions} | ${post.engagementRate.toFixed(1)}% | ${post.isBestPractice ? '✅' : '—'} |`
);
}
lines.push('');
}
// Top post
if (report.topPost) {
lines.push('### Top-Beitrag');
lines.push('');
lines.push(`**${report.topPost.title}**`);
lines.push(`- Format: ${report.topPost.format}`);
lines.push(`- Impressionen: ${report.topPost.impressions}`);
lines.push(`- Engagement-Rate: ${report.topPost.engagementRate.toFixed(1)}%`);
lines.push(`- Kommentare: ${report.topPost.comments} | Reposts: ${report.topPost.reposts}`);
lines.push('');
}
return lines;
}
function formatMonthlySection(trend: MonthlyTrend): string[] {
const lines: string[] = [];
lines.push('## Monatliche Trendanalyse');
lines.push(`Monat: ${trend.month}`);
lines.push('');
// Average metrics
lines.push('### Durchschnittliche Metriken');
lines.push('');
lines.push('| Metrik | Durchschnitt | Wachstum |');
lines.push('|--------|--------------|----------|');
lines.push(`| Profilaufrufe | ${trend.averageMetrics.profileViews.toFixed(0)} | ${formatChange(trend.growth.profileViews)} |`);
lines.push(`| Suchergebnisse | ${trend.averageMetrics.searchAppearances.toFixed(0)} | — |`);
lines.push(`| Impressionen | ${trend.averageMetrics.postImpressions.toFixed(0)} | ${formatChange(trend.growth.impressions)} |`);
lines.push(`| Engagement-Rate | ${trend.averageMetrics.engagementRate.toFixed(1)}% | ${formatChange(trend.growth.engagement)} |`);
lines.push('');
// Summary
lines.push('### Zusammenfassung');
lines.push('');
lines.push(`- Beiträge gesamt: ${trend.totalPosts}`);
lines.push(`- Best Practices: ${trend.bestPractices.length}`);
lines.push('');
// Best practices
if (trend.bestPractices.length > 0) {
lines.push('### Best-Practice-Beiträge');
lines.push('');
for (const post of trend.bestPractices) {
lines.push(`- **${post.title}** (${post.format}, ${post.date}) — ${post.engagementRate.toFixed(1)}% Engagement`);
}
lines.push('');
}
// Recommendations
if (trend.recommendations.length > 0) {
lines.push('### Empfehlungen');
lines.push('');
for (const rec of trend.recommendations) {
lines.push(`- ${rec}`);
}
lines.push('');
}
return lines;
}
// --- Helpers ---
function formatChange(change: number): string {
if (change === 0) return '±0%';
if (change > 0) return `+${change}%`;
return `${change}%`;
}
function getWeekStart(date: Date): string {
const d = new Date(date);
const day = d.getDay();
const diff = d.getDate() - day + (day === 0 ? -6 : 1); // Monday as start
d.setDate(diff);
return d.toISOString().split('T')[0];
}
+254
View File
@@ -0,0 +1,254 @@
/**
* LinkedIn Profile Module — Type Definitions
*
* TypeScript interfaces for all LinkedIn profile generation, content strategy,
* and tracking modules. Properties use camelCase consistent with project conventions.
*/
import type { ConstraintValidation } from './constraints';
// --- Headline Generator ---
/** Options for generating LinkedIn headline variants. */
export interface HeadlineOptions {
/** Reference to a Person entity ID */
personId: string;
/** Emphasis direction for the headline */
emphasis?: 'dual-role' | 'ai-expert' | 'leadership';
/** Number of headline variants to generate (default: 3) */
variants?: number;
}
/** Result of headline generation including all variants and validation. */
export interface HeadlineResult {
variants: HeadlineVariant[];
validation: ConstraintValidation;
}
/** A single headline variant with metadata. */
export interface HeadlineVariant {
/** The headline text */
text: string;
/** Character count of the headline */
charCount: number;
/** Keywords contained in the headline */
keywords: string[];
/** Roles mentioned in the headline */
roles: string[];
}
// --- About Section Generator ---
/** Options for generating the LinkedIn About section. */
export interface AboutOptions {
/** Reference to a Person entity ID */
personId: string;
/** Tone of the About section */
tone?: 'nahbar' | 'fachlich' | 'inspirierend';
/** Whether to include stats/numbers (default: true) */
includeStats?: boolean;
}
/** Result of About section generation. */
export interface AboutResult {
/** Complete About section text */
fullText: string;
/** Character count of the full text */
charCount: number;
/** First 2 lines visible before "mehr anzeigen" */
hookPreview: string;
/** Structured sections of the About text */
sections: {
hook: string;
mission: string;
expertise: string;
cta: string;
};
validation: ConstraintValidation;
}
// --- Experience Generator ---
/** Options for generating LinkedIn experience descriptions. */
export interface ExperienceOptions {
/** Reference to a Person entity ID */
personId: string;
/** Specific experience IDs to generate, or all if omitted */
experienceIds?: string[];
/** SEO keywords to include in descriptions */
keywords?: string[];
}
/** Result for a single experience description. */
export interface ExperienceResult {
/** Reference to the Experience entity ID */
experienceId: string;
/** Job title */
title: string;
/** Organization name */
organization: string;
/** Generated description text */
description: string;
/** Keywords included in the description */
keywords: string[];
/** Measurable achievements extracted/generated */
achievements: string[];
validation: ConstraintValidation;
}
// --- Content Strategy Generator ---
/** Options for generating a content strategy. */
export interface ContentStrategyOptions {
/** Reference to a Person entity ID */
personId: string;
/** Target quarter (e.g., "2026-Q3") */
quarter: string;
/** Existing content formats to integrate (e.g., podcast, column) */
existingFormats?: string[];
}
/** Complete content strategy for a quarter. */
export interface ContentStrategy {
/** Recommended posting frequency (e.g., "2 Beiträge pro Woche") */
postingFrequency: string;
/** Theme clusters with weighting */
themeCluster: ThemeCluster[];
/** Weekly posting plan */
weeklyPlan: WeeklySlot[];
/** Engagement routine definition */
engagementRoutine: EngagementRoutine;
/** Format distribution */
formatMix: FormatMix;
}
/** A theme cluster with topic and weight. */
export interface ThemeCluster {
/** Cluster name (e.g., "Innovation & Technologie") */
name: string;
/** Weight as percentage (e.g., 40) */
weight: number;
/** Example topics within this cluster */
topics: string[];
}
/** A weekly posting slot. */
export interface WeeklySlot {
/** Day of the week (e.g., "Dienstag") */
day: string;
/** Time of day for posting */
time: string;
/** Preferred format for this slot */
format: string;
/** Theme cluster this slot belongs to */
themeCluster: string;
}
/** Engagement routine definition. */
export interface EngagementRoutine {
/** Minimum comments per week on other posts */
commentsPerWeek: number;
/** Target accounts to engage with */
targetAccounts: string[];
/** Daily time investment for engagement */
dailyTimeMinutes: number;
}
/** Format distribution for content mix. */
export interface FormatMix {
/** Percentage of text posts */
text: number;
/** Percentage of carousel posts */
carousel: number;
/** Percentage of video posts */
video: number;
/** Percentage of newsletter posts */
newsletter: number;
}
// --- Tracking Manager ---
/** A single tracking entry for LinkedIn metrics. */
export interface TrackingEntry {
/** Unique identifier (e.g., "linkedin-2026-w28") */
id: string;
/** Entity type discriminator */
type: 'linkedin-tracking';
/** ISO date of the tracking entry */
date: string;
/** Aggregated metrics for the period */
metrics: {
profileViews: number;
searchAppearances: number;
connectionRequests: number;
postImpressions?: number;
engagementRate?: number;
comments?: number;
reposts?: number;
};
/** Individual post metrics for the period */
posts?: PostMetric[];
}
/** Metrics for a single LinkedIn post. */
export interface PostMetric {
/** Post title or first line */
title: string;
/** ISO date of publication */
date: string;
/** Content format */
format: 'text' | 'carousel' | 'video' | 'newsletter';
/** Total impressions */
impressions: number;
/** Engagement rate as percentage */
engagementRate: number;
/** Number of comments */
comments: number;
/** Number of reposts/shares */
reposts: number;
/** Whether this post is marked as best practice */
isBestPractice?: boolean;
}
/** Weekly performance report. */
export interface WeeklyReport {
/** ISO date of the week start */
weekOf: string;
/** Aggregated metrics for the week */
metrics: TrackingEntry['metrics'];
/** Posts published during the week */
posts: PostMetric[];
/** Top performing post of the week */
topPost?: PostMetric;
/** Comparison to previous week */
weekOverWeek: {
profileViewsChange: number;
impressionsChange: number;
engagementRateChange: number;
};
}
/** Monthly trend analysis. */
export interface MonthlyTrend {
/** Month identifier (e.g., "2026-07") */
month: string;
/** Average metrics across the month */
averageMetrics: {
profileViews: number;
searchAppearances: number;
postImpressions: number;
engagementRate: number;
};
/** Total posts published */
totalPosts: number;
/** Best performing posts of the month */
bestPractices: PostMetric[];
/** Growth compared to previous month as percentages */
growth: {
profileViews: number;
impressions: number;
engagement: number;
};
/** Data-driven recommendations for content optimization */
recommendations: string[];
}