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:
@@ -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('📊');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 + '…';
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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.1–1.5, 2.1–2.7, 3.1–3.6, 8.1–8.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);
|
||||
});
|
||||
});
|
||||
@@ -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'],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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%');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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.1–8.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];
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
Reference in New Issue
Block a user