aktueller stand

This commit is contained in:
2026-02-03 12:48:43 +01:00
parent e1ecd1a38c
commit b50594dbfa
77 changed files with 19139 additions and 0 deletions

20
src/agents/__init__.py Normal file
View File

@@ -0,0 +1,20 @@
"""AI Agents module."""
from src.agents.base import BaseAgent
from src.agents.profile_analyzer import ProfileAnalyzerAgent
from src.agents.topic_extractor import TopicExtractorAgent
from src.agents.researcher import ResearchAgent
from src.agents.writer import WriterAgent
from src.agents.critic import CriticAgent
from src.agents.post_classifier import PostClassifierAgent
from src.agents.post_type_analyzer import PostTypeAnalyzerAgent
__all__ = [
"BaseAgent",
"ProfileAnalyzerAgent",
"TopicExtractorAgent",
"ResearchAgent",
"WriterAgent",
"CriticAgent",
"PostClassifierAgent",
"PostTypeAnalyzerAgent",
]

120
src/agents/base.py Normal file
View File

@@ -0,0 +1,120 @@
"""Base agent class."""
import asyncio
from abc import ABC, abstractmethod
from typing import Any, Dict, Optional
from openai import OpenAI
import httpx
from loguru import logger
from src.config import settings
class BaseAgent(ABC):
"""Base class for all AI agents."""
def __init__(self, name: str):
"""
Initialize base agent.
Args:
name: Name of the agent
"""
self.name = name
self.openai_client = OpenAI(api_key=settings.openai_api_key)
logger.info(f"Initialized {name} agent")
@abstractmethod
async def process(self, *args, **kwargs) -> Any:
"""Process the agent's task."""
pass
async def call_openai(
self,
system_prompt: str,
user_prompt: str,
model: str = "gpt-4o",
temperature: float = 0.7,
response_format: Optional[Dict[str, str]] = None
) -> str:
"""
Call OpenAI API.
Args:
system_prompt: System message
user_prompt: User message
model: Model to use
temperature: Temperature for sampling
response_format: Optional response format (e.g., {"type": "json_object"})
Returns:
Assistant's response
"""
logger.info(f"[{self.name}] Calling OpenAI ({model})")
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt}
]
kwargs = {
"model": model,
"messages": messages,
"temperature": temperature
}
if response_format:
kwargs["response_format"] = response_format
# Run synchronous OpenAI call in thread pool to avoid blocking event loop
response = await asyncio.to_thread(
self.openai_client.chat.completions.create,
**kwargs
)
result = response.choices[0].message.content
logger.debug(f"[{self.name}] Received response (length: {len(result)})")
return result
async def call_perplexity(
self,
system_prompt: str,
user_prompt: str,
model: str = "sonar"
) -> str:
"""
Call Perplexity API for research.
Args:
system_prompt: System message
user_prompt: User message
model: Model to use
Returns:
Assistant's response
"""
logger.info(f"[{self.name}] Calling Perplexity ({model})")
url = "https://api.perplexity.ai/chat/completions"
headers = {
"Authorization": f"Bearer {settings.perplexity_api_key}",
"Content-Type": "application/json"
}
payload = {
"model": model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt}
]
}
async with httpx.AsyncClient() as client:
response = await client.post(url, json=payload, headers=headers, timeout=60.0)
response.raise_for_status()
result = response.json()
content = result["choices"][0]["message"]["content"]
logger.debug(f"[{self.name}] Received Perplexity response (length: {len(content)})")
return content

276
src/agents/critic.py Normal file
View File

@@ -0,0 +1,276 @@
"""Critic agent for reviewing and improving LinkedIn posts."""
import json
from typing import Dict, Any, Optional, List
from loguru import logger
from src.agents.base import BaseAgent
class CriticAgent(BaseAgent):
"""Agent for critically reviewing LinkedIn posts and suggesting improvements."""
def __init__(self):
"""Initialize critic agent."""
super().__init__("Critic")
async def process(
self,
post: str,
profile_analysis: Dict[str, Any],
topic: Dict[str, Any],
example_posts: Optional[List[str]] = None,
iteration: int = 1,
max_iterations: int = 3
) -> Dict[str, Any]:
"""
Review a LinkedIn post and provide feedback.
Args:
post: The post to review
profile_analysis: Profile analysis results
topic: Topic information
example_posts: Optional list of real posts to compare style against
iteration: Current iteration number (1-based)
max_iterations: Maximum number of iterations allowed
Returns:
Dictionary with approval status and feedback
"""
logger.info(f"Reviewing post for quality and authenticity (iteration {iteration}/{max_iterations})")
system_prompt = self._get_system_prompt(profile_analysis, example_posts, iteration, max_iterations)
user_prompt = self._get_user_prompt(post, topic, iteration, max_iterations)
response = await self.call_openai(
system_prompt=system_prompt,
user_prompt=user_prompt,
model="gpt-4o-mini",
temperature=0.3,
response_format={"type": "json_object"}
)
# Parse response
result = json.loads(response)
is_approved = result.get("approved", False)
logger.info(f"Post {'APPROVED' if is_approved else 'NEEDS REVISION'}")
return result
def _get_system_prompt(self, profile_analysis: Dict[str, Any], example_posts: Optional[List[str]] = None, iteration: int = 1, max_iterations: int = 3) -> str:
"""Get system prompt for critic - orientiert an bewährten n8n-Prompts."""
writing_style = profile_analysis.get("writing_style", {})
linguistic = profile_analysis.get("linguistic_fingerprint", {})
tone_analysis = profile_analysis.get("tone_analysis", {})
phrase_library = profile_analysis.get("phrase_library", {})
structure_templates = profile_analysis.get("structure_templates", {})
# Build example posts section for style comparison
examples_section = ""
if example_posts and len(example_posts) > 0:
examples_section = "\n\nECHTE POSTS DER PERSON (VERGLEICHE DEN STIL!):\n"
for i, post in enumerate(example_posts, 1):
post_text = post[:1200] + "..." if len(post) > 1200 else post
examples_section += f"\n--- Echtes Beispiel {i} ---\n{post_text}\n"
examples_section += "--- Ende Beispiele ---\n"
# Safe extraction of signature phrases
sig_phrases = linguistic.get('signature_phrases', [])
sig_phrases_str = ', '.join(sig_phrases) if sig_phrases else 'Keine spezifischen'
# Extract phrase library for style matching
hook_phrases = phrase_library.get('hook_phrases', [])
emotional_expressions = phrase_library.get('emotional_expressions', [])
cta_phrases = phrase_library.get('cta_phrases', [])
# Extract structure info
primary_structure = structure_templates.get('primary_structure', 'Hook → Body → CTA')
# Iteration-aware guidance
iteration_guidance = ""
if iteration == 1:
iteration_guidance = """
ERSTE ITERATION - Fokus auf die WICHTIGSTEN Verbesserungen:
- Konzentriere dich auf maximal 2-3 kritische Punkte
- Gib SEHR SPEZIFISCHE Änderungsanweisungen
- Kleine Stilnuancen können in späteren Iterationen optimiert werden
- Erwarteter Score-Bereich: 70-85 (selten höher beim ersten Entwurf)"""
elif iteration == max_iterations:
iteration_guidance = """
LETZTE ITERATION - Faire Endbewertung:
- Der Post wurde bereits überarbeitet - würdige die Verbesserungen!
- Prüfe: Hat der Writer die vorherigen Kritikpunkte umgesetzt?
- Wenn JA und der Post authentisch klingt: Score 85-95 ist angemessen
- Wenn der Post WIRKLICH exzellent ist (klingt wie ein echtes Beispiel): 95-100 möglich
- ABER: Keine Inflation! Nur 90+ wenn es wirklich verdient ist
- Kleine Imperfektionen sind OK bei 85-89, nicht bei 90+"""
else:
iteration_guidance = f"""
ITERATION {iteration}/{max_iterations} - Fortschritt anerkennen:
- Prüfe ob vorherige Kritikpunkte umgesetzt wurden
- Wenn Verbesserungen sichtbar: Score sollte steigen
- Fokussiere auf verbleibende Verbesserungen
- Erwarteter Score-Bereich: 75-90 (wenn erste Kritik gut umgesetzt)"""
return f"""ROLLE: Du bist ein präziser Chefredakteur für Personal Branding. Deine Aufgabe ist es, einen LinkedIn-Entwurf zu bewerten und NUR dort Korrekturen vorzuschlagen, wo er gegen die Identität des Absenders verstößt oder typische KI-Muster aufweist.
{examples_section}
{iteration_guidance}
REFERENZ-PROFIL (Der Maßstab):
Branche: {profile_analysis.get('audience_insights', {}).get('industry_context', 'Business')}
Perspektive: {writing_style.get('perspective', 'Ich-Perspektive')}
Ansprache: {writing_style.get('form_of_address', 'Du/Euch')}
Energie-Level: {linguistic.get('energy_level', 7)}/10 (1=sachlich, 10=explosiv)
Signature Phrases: {sig_phrases_str}
Tonalität: {tone_analysis.get('primary_tone', 'Professionell')}
Erwartete Struktur: {primary_structure}
PHRASEN-REFERENZ (Der Post sollte ÄHNLICHE Formulierungen nutzen - nicht identisch, aber im gleichen Stil):
- Hook-Stil Beispiele: {', '.join(hook_phrases[:3]) if hook_phrases else 'Keine verfügbar'}
- Emotionale Ausdrücke: {', '.join(emotional_expressions[:3]) if emotional_expressions else 'Keine verfügbar'}
- CTA-Stil Beispiele: {', '.join(cta_phrases[:2]) if cta_phrases else 'Keine verfügbar'}
CHIRURGISCHE KORREKTUR-REGELN (Prüfe diese Punkte!):
1. SATZBAU-OPTIMIERUNG:
- Keine Gedankenstriche () zur Satzverbindung - diese wirken zu konstruiert
- Wenn Gedankenstriche gefunden werden: Vorschlagen, durch Kommas, Punkte oder Konjunktionen zu ersetzen
- Zwei eigenständige Sätze sind oft besser als ein verbundener
2. ANSPRACHE-CHECK:
- Prüfe: Nutzt der Text konsequent die Form {writing_style.get('form_of_address', 'Du/Euch')}?
- Falls inkonsistent (z.B. Sie statt Du oder umgekehrt): Als Fehler markieren
3. PERSPEKTIV-CHECK (Priorität 1!):
- Wenn das Profil {writing_style.get('perspective', 'Ich-Perspektive')} verlangt:
- Belehrende "Sie/Euch"-Sätze ("Stellt euch vor", "Ihr solltet") in Reflexionen umwandeln
- Besser: "Ich sehe immer wieder...", "Ich frage mich oft..." statt direkter Handlungsaufforderungen
4. KI-MUSTER ERKENNEN:
- "In der heutigen Zeit", "Tauchen Sie ein", "Es ist kein Geheimnis" = SOFORT bemängeln
- "Stellen Sie sich vor", "Lassen Sie uns" = KI-typisch
- Zu perfekte, glatte Formulierungen ohne Ecken und Kanten
5. ENERGIE-ABGLEICH:
- Passt die Intensität zum Energie-Level ({linguistic.get('energy_level', 7)}/10)?
- Zu lahm bei hohem Level oder zu überdreht bei niedrigem Level = Korrektur vorschlagen
6. UNICODE & FORMATIERUNG:
- Prüfe den Hook: Ist Unicode-Fettung korrekt? (Umlaute ä, ö, ü, ß dürfen nicht zerstört sein)
- Keine Markdown-Sterne (**) - LinkedIn unterstützt das nicht
- Keine Trennlinien (---)
7. PHRASEN & STRUKTUR-MATCH:
- Vergleiche den Stil mit den Phrasen-Referenzen oben
- Der Hook sollte IM GLEICHEN STIL sein wie die Hook-Beispiele (nicht identisch kopiert!)
- Emotionale Ausdrücke sollten ÄHNLICH sein (wenn die Person "Halleluja!" nutzt, sollte der Post auch emotionale Ausrufe haben)
- Der CTA sollte im gleichen Stil sein wie die CTA-Beispiele
- WICHTIG: Es geht um den STIL, nicht um wörtliches Kopieren!
BEWERTUNGSKRITERIEN (100 Punkte total):
1. Authentizität & Stil-Match (40 Punkte)
- Klingt wie die echte Person (vergleiche mit Beispiel-Posts!)
- Keine KI-Muster erkennbar
- Richtige Energie und Tonalität
- Nutzt ÄHNLICHE Phrasen/Formulierungen wie in der Phrasen-Referenz (nicht identisch kopiert, aber im gleichen Stil!)
- Hat die Person typische emotionale Ausdrücke? Sind welche im Post?
2. Content-Qualität (35 Punkte)
- Starker, aufmerksamkeitsstarker Hook (vergleiche mit Hook-Beispielen!)
- Klarer Mehrwert für die Zielgruppe
- Gute Struktur und Lesefluss (folgt der erwarteten Struktur: {primary_structure})
- Passender CTA (vergleiche mit CTA-Beispielen!)
3. Technische Korrektheit (25 Punkte)
- Richtige Perspektive und Ansprache (konsistent!)
- Angemessene Länge (~{writing_style.get('average_word_count', 300)} Wörter)
- Korrekte Formatierung
SCORE-KALIBRIERUNG (WICHTIG - lies das genau!):
**90-100 Punkte = Exzellent, direkt veröffentlichbar**
- 100: Herausragend - Post klingt EXAKT wie die echte Person, perfekter Hook, null KI-Muster
- 95-99: Exzellent - Kaum von echtem Post unterscheidbar, minimale Verbesserungsmöglichkeiten
- 90-94: Sehr gut - Authentisch, professionell, kleine Stilnuancen könnten besser sein
**85-89 Punkte = Gut, veröffentlichungsreif**
- Der Post funktioniert, erfüllt alle wichtigen Kriterien
- Vielleicht 1-2 Formulierungen die noch besser sein könnten
**75-84 Punkte = Solide Basis, aber Verbesserungen nötig**
- Grundstruktur stimmt, aber erkennbare Probleme
- Entweder KI-Muster, Stil-Mismatch oder technische Fehler
**< 75 Punkte = Wesentliche Überarbeitung nötig**
- Mehrere gravierende Probleme
- Klingt nicht authentisch oder hat strukturelle Mängel
APPROVAL-SCHWELLEN:
- >= 85 Punkte: APPROVED (veröffentlichungsreif)
- 75-84 Punkte: Fast fertig, kleine Anpassungen
- < 75 Punkte: Überarbeitung nötig
WICHTIG: Gib 90+ Punkte wenn der Post es VERDIENT - nicht aus Großzügigkeit!
Ein Post der wirklich authentisch klingt und keine KI-Muster hat, SOLLTE 90+ bekommen.
WICHTIG FÜR DEIN FEEDBACK:
- Gib EXAKTE Formulierungsvorschläge: "Ändere 'X' zu 'Y'" (nicht "verbessere den Hook")
- Maximal 3 konkrete Änderungen pro Iteration
- Erkenne umgesetzte Verbesserungen an und erhöhe den Score entsprechend
- Bei der letzten Iteration: Sei fair - gib 90+ wenn der Post es verdient, aber nicht aus Milde
Antworte als JSON."""
def _get_user_prompt(self, post: str, topic: Dict[str, Any], iteration: int = 1, max_iterations: int = 3) -> str:
"""Get user prompt for critic."""
iteration_note = ""
if iteration > 1:
iteration_note = f"\n**HINWEIS:** Dies ist Iteration {iteration} von {max_iterations}. Der Post wurde bereits überarbeitet.\n"
if iteration == max_iterations:
iteration_note += """**FINALE BEWERTUNG:**
- Würdige umgesetzte Verbesserungen mit höherem Score
- 85+ = APPROVED wenn der Post authentisch und fehlerfrei ist
- 90+ = Nur wenn der Post wirklich exzellent ist (vergleiche mit echten Beispielen!)
- Sei fair, nicht großzügig - Qualität bleibt der Maßstab.\n"""
return f"""Bewerte diesen LinkedIn-Post:
{iteration_note}
**THEMA:** {topic.get('title', 'Unknown')}
**POST:**
{post}
---
Antworte im JSON-Format:
{{
"approved": true/false,
"overall_score": 0-100,
"scores": {{
"authenticity_and_style": 0-40,
"content_quality": 0-35,
"technical_execution": 0-25
}},
"strengths": ["Stärke 1", "Stärke 2"],
"improvements": ["Verbesserung 1", "Verbesserung 2"],
"feedback": "Kurze Zusammenfassung",
"specific_changes": [
{{
"original": "Exakter Text aus dem Post der geändert werden soll",
"replacement": "Der neue vorgeschlagene Text",
"reason": "Warum diese Änderung"
}}
]
}}
WICHTIG bei specific_changes:
- Gib EXAKTE Textstellen an die geändert werden sollen
- Maximal 3 Changes pro Iteration
- Der "original" Text muss EXAKT im Post vorkommen"""

View File

@@ -0,0 +1,279 @@
"""Post classifier agent for categorizing LinkedIn posts into post types."""
import json
import re
from typing import Dict, Any, List, Optional, Tuple
from uuid import UUID
from loguru import logger
from src.agents.base import BaseAgent
from src.database.models import LinkedInPost, PostType
class PostClassifierAgent(BaseAgent):
"""Agent for classifying LinkedIn posts into defined post types."""
def __init__(self):
"""Initialize post classifier agent."""
super().__init__("PostClassifier")
async def process(
self,
posts: List[LinkedInPost],
post_types: List[PostType]
) -> List[Dict[str, Any]]:
"""
Classify posts into post types.
Uses a two-phase approach:
1. Hashtag matching (fast, deterministic)
2. Semantic matching via LLM (for posts without hashtag match)
Args:
posts: List of posts to classify
post_types: List of available post types
Returns:
List of classification results with post_id, post_type_id, method, confidence
"""
if not posts or not post_types:
logger.warning("No posts or post types to classify")
return []
logger.info(f"Classifying {len(posts)} posts into {len(post_types)} post types")
classifications = []
posts_needing_semantic = []
# Phase 1: Hashtag matching
for post in posts:
result = self._match_by_hashtags(post, post_types)
if result:
classifications.append(result)
else:
posts_needing_semantic.append(post)
logger.info(f"Hashtag matching: {len(classifications)} matched, {len(posts_needing_semantic)} need semantic")
# Phase 2: Semantic matching for remaining posts
if posts_needing_semantic:
semantic_results = await self._match_semantically(posts_needing_semantic, post_types)
classifications.extend(semantic_results)
logger.info(f"Classification complete: {len(classifications)} total classifications")
return classifications
def _extract_hashtags(self, text: str) -> List[str]:
"""Extract hashtags from post text (lowercase for matching)."""
hashtags = re.findall(r'#(\w+)', text)
return [h.lower() for h in hashtags]
def _match_by_hashtags(
self,
post: LinkedInPost,
post_types: List[PostType]
) -> Optional[Dict[str, Any]]:
"""
Try to match post to a post type by hashtags.
Args:
post: The post to classify
post_types: Available post types
Returns:
Classification dict or None if no match
"""
post_hashtags = set(self._extract_hashtags(post.post_text))
if not post_hashtags:
return None
best_match = None
best_match_count = 0
for pt in post_types:
if not pt.identifying_hashtags:
continue
# Convert post type hashtags to lowercase for comparison
pt_hashtags = set(h.lower().lstrip('#') for h in pt.identifying_hashtags)
# Count matching hashtags
matches = post_hashtags.intersection(pt_hashtags)
if matches and len(matches) > best_match_count:
best_match = pt
best_match_count = len(matches)
if best_match:
# Confidence based on how many hashtags matched
confidence = min(1.0, best_match_count * 0.25 + 0.5)
return {
"post_id": post.id,
"post_type_id": best_match.id,
"classification_method": "hashtag",
"classification_confidence": confidence
}
return None
async def _match_semantically(
self,
posts: List[LinkedInPost],
post_types: List[PostType]
) -> List[Dict[str, Any]]:
"""
Match posts to post types using semantic analysis via LLM.
Args:
posts: Posts to classify
post_types: Available post types
Returns:
List of classification results
"""
if not posts:
return []
# Build post type descriptions for the LLM
type_descriptions = []
for pt in post_types:
desc = f"- **{pt.name}** (ID: {pt.id})"
if pt.description:
desc += f": {pt.description}"
if pt.identifying_keywords:
desc += f"\n Keywords: {', '.join(pt.identifying_keywords[:10])}"
if pt.semantic_properties:
props = pt.semantic_properties
if props.get("purpose"):
desc += f"\n Purpose: {props['purpose']}"
if props.get("typical_tone"):
desc += f"\n Tone: {props['typical_tone']}"
type_descriptions.append(desc)
type_descriptions_text = "\n".join(type_descriptions)
# Process in batches for efficiency
batch_size = 10
results = []
for i in range(0, len(posts), batch_size):
batch = posts[i:i + batch_size]
batch_results = await self._classify_batch(batch, post_types, type_descriptions_text)
results.extend(batch_results)
return results
async def _classify_batch(
self,
posts: List[LinkedInPost],
post_types: List[PostType],
type_descriptions: str
) -> List[Dict[str, Any]]:
"""Classify a batch of posts using LLM."""
# Build post list for prompt
posts_list = []
for i, post in enumerate(posts):
post_preview = post.post_text[:500] + "..." if len(post.post_text) > 500 else post.post_text
posts_list.append(f"[Post {i + 1}] (ID: {post.id})\n{post_preview}")
posts_text = "\n\n".join(posts_list)
# Build valid type IDs for validation
valid_type_ids = {str(pt.id) for pt in post_types}
valid_type_ids.add("null") # Allow unclassified
system_prompt = """Du bist ein Content-Analyst, der LinkedIn-Posts in vordefinierte Kategorien einordnet.
Analysiere jeden Post und ordne ihn dem passendsten Post-Typ zu.
Wenn kein Typ wirklich passt, gib "null" als post_type_id zurück.
Bewerte die Zuordnung mit einer Confidence zwischen 0.3 und 1.0:
- 0.9-1.0: Sehr sicher, Post passt perfekt zum Typ
- 0.7-0.9: Gute Übereinstimmung
- 0.5-0.7: Moderate Übereinstimmung
- 0.3-0.5: Schwache Übereinstimmung, aber beste verfügbare Option
Antworte im JSON-Format."""
user_prompt = f"""Ordne die folgenden Posts den verfügbaren Post-Typen zu:
=== VERFÜGBARE POST-TYPEN ===
{type_descriptions}
=== POSTS ZUM KLASSIFIZIEREN ===
{posts_text}
=== ANTWORT-FORMAT ===
Gib ein JSON-Objekt zurück mit diesem Format:
{{
"classifications": [
{{
"post_id": "uuid-des-posts",
"post_type_id": "uuid-des-typs oder null",
"confidence": 0.8,
"reasoning": "Kurze Begründung"
}}
]
}}"""
try:
response = await self.call_openai(
system_prompt=system_prompt,
user_prompt=user_prompt,
model="gpt-4o-mini",
temperature=0.2,
response_format={"type": "json_object"}
)
result = json.loads(response)
classifications = result.get("classifications", [])
# Process and validate results
valid_results = []
for c in classifications:
post_id = c.get("post_id")
post_type_id = c.get("post_type_id")
confidence = c.get("confidence", 0.5)
# Validate post_id exists
matching_post = next((p for p in posts if str(p.id) == post_id), None)
if not matching_post:
logger.warning(f"Invalid post_id in classification: {post_id}")
continue
# Validate post_type_id
if post_type_id and post_type_id != "null" and post_type_id not in valid_type_ids:
logger.warning(f"Invalid post_type_id in classification: {post_type_id}")
continue
if post_type_id and post_type_id != "null":
valid_results.append({
"post_id": matching_post.id,
"post_type_id": UUID(post_type_id),
"classification_method": "semantic",
"classification_confidence": min(1.0, max(0.3, confidence))
})
return valid_results
except Exception as e:
logger.error(f"Semantic classification failed: {e}")
return []
async def classify_single_post(
self,
post: LinkedInPost,
post_types: List[PostType]
) -> Optional[Dict[str, Any]]:
"""
Classify a single post.
Args:
post: The post to classify
post_types: Available post types
Returns:
Classification result or None
"""
results = await self.process([post], post_types)
return results[0] if results else None

View File

@@ -0,0 +1,335 @@
"""Post type analyzer agent for creating intensive analysis per post type."""
import json
import re
from typing import Dict, Any, List
from loguru import logger
from src.agents.base import BaseAgent
from src.database.models import LinkedInPost, PostType
class PostTypeAnalyzerAgent(BaseAgent):
"""Agent for analyzing post types based on their classified posts."""
MIN_POSTS_FOR_ANALYSIS = 3 # Minimum posts needed for meaningful analysis
def __init__(self):
"""Initialize post type analyzer agent."""
super().__init__("PostTypeAnalyzer")
async def process(
self,
post_type: PostType,
posts: List[LinkedInPost]
) -> Dict[str, Any]:
"""
Analyze a post type based on its posts.
Args:
post_type: The post type to analyze
posts: Posts belonging to this type
Returns:
Analysis dictionary with patterns and insights
"""
if len(posts) < self.MIN_POSTS_FOR_ANALYSIS:
logger.warning(f"Not enough posts for analysis: {len(posts)} < {self.MIN_POSTS_FOR_ANALYSIS}")
return {
"error": f"Mindestens {self.MIN_POSTS_FOR_ANALYSIS} Posts benötigt",
"post_count": len(posts),
"sufficient_data": False
}
logger.info(f"Analyzing post type '{post_type.name}' with {len(posts)} posts")
# Prepare posts for analysis
posts_text = self._prepare_posts_for_analysis(posts)
# Get comprehensive analysis from LLM
analysis = await self._analyze_posts(post_type, posts_text, len(posts))
# Add metadata
analysis["post_count"] = len(posts)
analysis["sufficient_data"] = True
analysis["post_type_name"] = post_type.name
logger.info(f"Analysis complete for '{post_type.name}'")
return analysis
def _prepare_posts_for_analysis(self, posts: List[LinkedInPost]) -> str:
"""Prepare posts text for analysis."""
posts_sections = []
for i, post in enumerate(posts, 1):
# Include full post text
posts_sections.append(f"=== POST {i} ===\n{post.post_text}\n=== ENDE POST {i} ===")
return "\n\n".join(posts_sections)
async def _analyze_posts(
self,
post_type: PostType,
posts_text: str,
post_count: int
) -> Dict[str, Any]:
"""Run comprehensive analysis on posts."""
system_prompt = """Du bist ein erfahrener LinkedIn Content-Analyst und Ghostwriter-Coach.
Deine Aufgabe ist es, Muster und Stilelemente aus einer Sammlung von Posts zu extrahieren,
um einen "Styleguide" für diesen Post-Typ zu erstellen.
Sei SEHR SPEZIFISCH und nutze ECHTE BEISPIELE aus den Posts!
Keine generischen Beschreibungen - immer konkrete Auszüge und Formulierungen.
Antworte im JSON-Format."""
user_prompt = f"""Analysiere die folgenden {post_count} Posts vom Typ "{post_type.name}".
{f'Beschreibung: {post_type.description}' if post_type.description else ''}
=== DIE POSTS ===
{posts_text}
=== DEINE ANALYSE ===
Erstelle eine detaillierte Analyse im folgenden JSON-Format:
{{
"structure_patterns": {{
"typical_structure": "Beschreibe die typische Struktur (z.B. Hook → Problem → Lösung → CTA)",
"paragraph_count": "Typische Anzahl Absätze",
"paragraph_length": "Typische Absatzlänge in Worten",
"uses_lists": true/false,
"list_style": "Wenn Listen: Wie werden sie formatiert? (Bullets, Nummern, Emojis)",
"structure_template": "Eine Vorlage für die Struktur"
}},
"language_style": {{
"tone": "Haupttonalität (z.B. inspirierend, sachlich, provokativ)",
"secondary_tones": ["Weitere Tonalitäten"],
"perspective": "Ich-Perspektive, Du-Ansprache, Wir-Form?",
"energy_level": 1-10,
"formality": "formell/informell/mix",
"sentence_types": "Kurz und knackig vs. ausführlich vs. mix",
"typical_sentence_starters": ["Echte Beispiele wie Sätze beginnen"],
"signature_phrases": ["Wiederkehrende Formulierungen"]
}},
"hooks": {{
"hook_types": ["Welche Hook-Arten werden verwendet (Frage, Statement, Statistik, Story...)"],
"real_examples": [
{{
"hook": "Der genaue Hook-Text",
"type": "Art des Hooks",
"why_effective": "Warum funktioniert er?"
}}
],
"hook_patterns": ["Muster die sich wiederholen"],
"average_hook_length": "Wie lang sind Hooks typischerweise?"
}},
"ctas": {{
"cta_types": ["Welche CTA-Arten (Frage, Aufforderung, Teilen-Bitte...)"],
"real_examples": [
{{
"cta": "Der genaue CTA-Text",
"type": "Art des CTAs"
}}
],
"cta_position": "Wo steht der CTA typischerweise?",
"cta_intensity": "Wie direkt/stark ist der CTA?"
}},
"visual_patterns": {{
"emoji_usage": {{
"frequency": "hoch/mittel/niedrig/keine",
"typical_emojis": ["Die häufigsten Emojis"],
"placement": "Wo werden Emojis platziert?",
"purpose": "Wofür werden sie genutzt?"
}},
"line_breaks": "Wie werden Absätze/Zeilenumbrüche genutzt?",
"formatting": "Unicode-Fett, Großbuchstaben, Sonderzeichen?",
"whitespace": "Viel/wenig Whitespace?"
}},
"length_patterns": {{
"average_words": "Durchschnittliche Wortanzahl",
"range": "Von-bis Wortanzahl",
"ideal_length": "Empfohlene Länge für diesen Typ"
}},
"recurring_elements": {{
"phrases": ["Wiederkehrende Phrasen und Formulierungen"],
"transitions": ["Typische Übergänge zwischen Absätzen"],
"closings": ["Typische Schlussformulierungen vor dem CTA"]
}},
"content_focus": {{
"main_themes": ["Hauptthemen dieses Post-Typs"],
"value_proposition": "Welchen Mehrwert bieten diese Posts?",
"target_emotion": "Welche Emotion soll beim Leser ausgelöst werden?"
}},
"writing_guidelines": {{
"dos": ["5-7 konkrete Empfehlungen was man TUN sollte"],
"donts": ["3-5 konkrete Dinge die man VERMEIDEN sollte"],
"key_success_factors": ["Was macht Posts dieses Typs erfolgreich?"]
}}
}}
WICHTIG:
- Nutze ECHTE Textauszüge aus den Posts als Beispiele!
- Sei spezifisch, nicht generisch
- Wenn ein Muster nur in 1-2 Posts vorkommt, erwähne es trotzdem aber markiere es als "vereinzelt"
- Alle Beispiele müssen aus den gegebenen Posts stammen"""
try:
response = await self.call_openai(
system_prompt=system_prompt,
user_prompt=user_prompt,
model="gpt-4o",
temperature=0.3,
response_format={"type": "json_object"}
)
analysis = json.loads(response)
return analysis
except Exception as e:
logger.error(f"Analysis failed: {e}")
return {
"error": str(e),
"sufficient_data": True,
"post_count": post_count
}
async def analyze_multiple_types(
self,
post_types_with_posts: List[Dict[str, Any]]
) -> Dict[str, Dict[str, Any]]:
"""
Analyze multiple post types.
Args:
post_types_with_posts: List of dicts with 'post_type' and 'posts' keys
Returns:
Dictionary mapping post_type_id to analysis
"""
results = {}
for item in post_types_with_posts:
post_type = item["post_type"]
posts = item["posts"]
try:
analysis = await self.process(post_type, posts)
results[str(post_type.id)] = analysis
except Exception as e:
logger.error(f"Failed to analyze post type {post_type.name}: {e}")
results[str(post_type.id)] = {
"error": str(e),
"sufficient_data": False
}
return results
def get_writing_prompt_section(self, analysis: Dict[str, Any]) -> str:
"""
Generate a prompt section for the writer based on the analysis.
Args:
analysis: The post type analysis
Returns:
Formatted string for inclusion in writer prompts
"""
if not analysis.get("sufficient_data"):
return ""
sections = []
# Structure
if structure := analysis.get("structure_patterns"):
sections.append(f"""
STRUKTUR FÜR DIESEN POST-TYP:
- Typische Struktur: {structure.get('typical_structure', 'Standard')}
- Absätze: {structure.get('paragraph_count', '3-5')} Absätze
- Listen: {'Ja' if structure.get('uses_lists') else 'Nein'}
{f"- Listen-Stil: {structure.get('list_style')}" if structure.get('uses_lists') else ''}
""")
# Language style
if style := analysis.get("language_style"):
sections.append(f"""
SPRACH-STIL:
- Tonalität: {style.get('tone', 'Professionell')}
- Perspektive: {style.get('perspective', 'Ich')}
- Energie-Level: {style.get('energy_level', 7)}/10
- Formalität: {style.get('formality', 'informell')}
Typische Satzanfänge:
{chr(10).join([f' - "{s}"' for s in style.get('typical_sentence_starters', [])[:5]])}
Signature Phrases:
{chr(10).join([f' - "{p}"' for p in style.get('signature_phrases', [])[:5]])}
""")
# Hooks
if hooks := analysis.get("hooks"):
hook_examples = hooks.get("real_examples", [])[:3]
hook_text = "\n".join([f' - "{h.get("hook", "")}" ({h.get("type", "")})' for h in hook_examples])
sections.append(f"""
HOOK-MUSTER:
Hook-Typen: {', '.join(hooks.get('hook_types', []))}
Echte Beispiele:
{hook_text}
Muster: {', '.join(hooks.get('hook_patterns', [])[:3])}
""")
# CTAs
if ctas := analysis.get("ctas"):
cta_examples = ctas.get("real_examples", [])[:3]
cta_text = "\n".join([f' - "{c.get("cta", "")}"' for c in cta_examples])
sections.append(f"""
CTA-MUSTER:
CTA-Typen: {', '.join(ctas.get('cta_types', []))}
Echte Beispiele:
{cta_text}
Position: {ctas.get('cta_position', 'Am Ende')}
""")
# Visual patterns
if visual := analysis.get("visual_patterns"):
emoji = visual.get("emoji_usage", {})
sections.append(f"""
VISUELLE ELEMENTE:
- Emoji-Nutzung: {emoji.get('frequency', 'mittel')}
- Typische Emojis: {' '.join(emoji.get('typical_emojis', [])[:8])}
- Platzierung: {emoji.get('placement', 'Variabel')}
- Formatierung: {visual.get('formatting', 'Standard')}
""")
# Length
if length := analysis.get("length_patterns"):
sections.append(f"""
LÄNGE:
- Ideal: ca. {length.get('ideal_length', '200-300')} Wörter
- Range: {length.get('range', '150-400')} Wörter
""")
# Guidelines
if guidelines := analysis.get("writing_guidelines"):
dos = guidelines.get("dos", [])[:5]
donts = guidelines.get("donts", [])[:3]
sections.append(f"""
WICHTIGE REGELN:
DO:
{chr(10).join([f'{d}' for d in dos])}
DON'T:
{chr(10).join([f'{d}' for d in donts])}
""")
return "\n".join(sections)

View File

@@ -0,0 +1,300 @@
"""Profile analyzer agent."""
import json
from typing import Dict, Any, List
from loguru import logger
from src.agents.base import BaseAgent
from src.database.models import LinkedInProfile, LinkedInPost
class ProfileAnalyzerAgent(BaseAgent):
"""Agent for analyzing LinkedIn profiles and extracting writing patterns."""
def __init__(self):
"""Initialize profile analyzer agent."""
super().__init__("ProfileAnalyzer")
async def process(
self,
profile: LinkedInProfile,
posts: List[LinkedInPost],
customer_data: Dict[str, Any]
) -> Dict[str, Any]:
"""
Analyze LinkedIn profile and extract writing patterns.
Args:
profile: LinkedIn profile data
posts: List of LinkedIn posts
customer_data: Additional customer data from input file
Returns:
Comprehensive profile analysis
"""
logger.info(f"Analyzing profile for: {profile.name}")
# Prepare analysis data
profile_summary = {
"name": profile.name,
"headline": profile.headline,
"summary": profile.summary,
"industry": profile.industry,
"location": profile.location
}
# Prepare posts with engagement data - use up to 30 posts
posts_with_engagement = self._prepare_posts_for_analysis(posts[:15])
# Also identify top performing posts by engagement
top_posts = self._get_top_performing_posts(posts, limit=5)
system_prompt = self._get_system_prompt()
user_prompt = self._get_user_prompt(profile_summary, posts_with_engagement, top_posts, customer_data)
response = await self.call_openai(
system_prompt=system_prompt,
user_prompt=user_prompt,
model="gpt-4o",
temperature=0.3,
response_format={"type": "json_object"}
)
# Parse JSON response
analysis = json.loads(response)
logger.info("Profile analysis completed successfully")
return analysis
def _prepare_posts_for_analysis(self, posts: List[LinkedInPost]) -> List[Dict[str, Any]]:
"""Prepare posts with engagement data for analysis."""
prepared = []
for i, post in enumerate(posts):
if not post.post_text:
continue
prepared.append({
"index": i + 1,
"text": post.post_text,
"likes": post.likes or 0,
"comments": post.comments or 0,
"shares": post.shares or 0,
"engagement_total": (post.likes or 0) + (post.comments or 0) * 2 + (post.shares or 0) * 3
})
return prepared
def _get_top_performing_posts(self, posts: List[LinkedInPost], limit: int = 5) -> List[Dict[str, Any]]:
"""Get top performing posts by engagement."""
posts_with_engagement = []
for post in posts:
if not post.post_text or len(post.post_text) < 50:
continue
engagement = (post.likes or 0) + (post.comments or 0) * 2 + (post.shares or 0) * 3
posts_with_engagement.append({
"text": post.post_text,
"likes": post.likes or 0,
"comments": post.comments or 0,
"shares": post.shares or 0,
"engagement_score": engagement
})
# Sort by engagement and return top posts
sorted_posts = sorted(posts_with_engagement, key=lambda x: x["engagement_score"], reverse=True)
return sorted_posts[:limit]
def _get_system_prompt(self) -> str:
"""Get system prompt for profile analysis."""
return """Du bist ein hochspezialisierter AI-Analyst für LinkedIn-Profile und Content-Strategie.
Deine Aufgabe ist es, aus LinkedIn-Profildaten und Posts ein umfassendes Content-Analyse-Profil zu erstellen, das als BLAUPAUSE für das Schreiben neuer Posts dient.
WICHTIG: Extrahiere ECHTE BEISPIELE aus den Posts! Keine generischen Beschreibungen.
Das Profil soll folgende Dimensionen analysieren:
1. **Schreibstil & Tonalität**
- Wie schreibt die Person? (formal, locker, inspirierend, provokativ, etc.)
- Welche Perspektive wird genutzt? (Ich, Wir, Man)
- Wie ist die Ansprache? (Du, Sie, neutral)
- Satzdynamik und Rhythmus
2. **Phrasen-Bibliothek (KRITISCH!)**
- Hook-Phrasen: Wie beginnen Posts? Extrahiere 5-10 ECHTE Beispiele!
- Übergangs-Phrasen: Wie werden Absätze verbunden?
- Emotionale Ausdrücke: Ausrufe, Begeisterung, etc.
- CTA-Phrasen: Wie werden Leser aktiviert?
- Signature Phrases: Wiederkehrende Markenzeichen
3. **Struktur-Templates**
- Analysiere die STRUKTUR der Top-Posts
- Erstelle 2-3 konkrete Templates (z.B. "Hook → Flashback → Erkenntnis → CTA")
- Typische Satzanfänge für jeden Abschnitt
4. **Visuelle Muster**
- Emoji-Nutzung (welche, wo, wie oft)
- Unicode-Formatierung (fett, kursiv)
- Strukturierung (Absätze, Listen, etc.)
5. **Audience Insights**
- Wer ist die Zielgruppe?
- Welche Probleme werden adressiert?
- Welcher Mehrwert wird geboten?
Gib deine Analyse als strukturiertes JSON zurück."""
def _get_user_prompt(
self,
profile_summary: Dict[str, Any],
posts_with_engagement: List[Dict[str, Any]],
top_posts: List[Dict[str, Any]],
customer_data: Dict[str, Any]
) -> str:
"""Get user prompt with data for analysis."""
# Format all posts with engagement data
all_posts_text = ""
for post in posts_with_engagement:
all_posts_text += f"\n--- Post {post['index']} (Likes: {post['likes']}, Comments: {post['comments']}, Shares: {post['shares']}) ---\n"
all_posts_text += post['text'][:2000] # Limit each post to 2000 chars
all_posts_text += "\n"
# Format top performing posts
top_posts_text = ""
if top_posts:
for i, post in enumerate(top_posts, 1):
top_posts_text += f"\n--- TOP POST {i} (Engagement Score: {post['engagement_score']}, Likes: {post['likes']}, Comments: {post['comments']}) ---\n"
top_posts_text += post['text'][:2000]
top_posts_text += "\n"
return f"""Bitte analysiere folgendes LinkedIn-Profil BASIEREND AUF DEN ECHTEN POSTS:
**PROFIL-INFORMATIONEN:**
- Name: {profile_summary.get('name', 'N/A')}
- Headline: {profile_summary.get('headline', 'N/A')}
- Branche: {profile_summary.get('industry', 'N/A')}
- Location: {profile_summary.get('location', 'N/A')}
- Summary: {profile_summary.get('summary', 'N/A')}
**ZUSÄTZLICHE KUNDENDATEN (Persona, Style Guide, etc.):**
{json.dumps(customer_data, indent=2, ensure_ascii=False)}
**TOP-PERFORMING POSTS (die erfolgreichsten Posts - ANALYSIERE DIESE BESONDERS GENAU!):**
{top_posts_text if top_posts_text else "Keine Engagement-Daten verfügbar"}
**ALLE POSTS ({len(posts_with_engagement)} Posts mit Engagement-Daten):**
{all_posts_text}
---
WICHTIG: Analysiere die ECHTEN POSTS sehr genau! Deine Analyse muss auf den tatsächlichen Mustern basieren, nicht auf Annahmen. Extrahiere WÖRTLICHE ZITATE wo möglich!
Achte besonders auf:
1. Die TOP-PERFORMING Posts - was macht sie erfolgreich?
2. Wiederkehrende Phrasen und Formulierungen - WÖRTLICH extrahieren!
3. Wie beginnen die Posts (Hooks)? - ECHTE BEISPIELE sammeln!
4. Wie enden die Posts (CTAs)?
5. Emoji-Verwendung (welche, wo, wie oft)
6. Länge und Struktur der Absätze
7. Typische Satzanfänge und Übergänge
Erstelle eine umfassende Analyse im folgenden JSON-Format:
{{
"writing_style": {{
"tone": "Beschreibung der Tonalität basierend auf den echten Posts",
"perspective": "Ich/Wir/Man/Gemischt - mit Beispielen aus den Posts",
"form_of_address": "Du/Sie/Neutral - wie spricht die Person die Leser an?",
"sentence_dynamics": "Kurze Sätze? Lange Sätze? Mischung? Fragen?",
"average_post_length": "Kurz/Mittel/Lang",
"average_word_count": 0
}},
"linguistic_fingerprint": {{
"energy_level": 0,
"shouting_usage": "Beschreibung mit konkreten Beispielen aus den Posts",
"punctuation_patterns": "Beschreibung (!!!, ..., ?, etc.)",
"signature_phrases": ["ECHTE Phrasen aus den Posts", "die wiederholt vorkommen"],
"narrative_anchors": ["Storytelling-Elemente", "die die Person nutzt"]
}},
"phrase_library": {{
"hook_phrases": [
"ECHTE Hook-Sätze aus den Posts wörtlich kopiert",
"Mindestens 5-8 verschiedene Beispiele",
"z.B. '𝗞𝗜-𝗦𝘂𝗰𝗵𝗲 𝗶𝘀𝘁 𝗱𝗲𝗿 𝗲𝗿𝘀𝘁𝗲 𝗦𝗰𝗵𝗿𝗶𝘁𝘁 𝗶𝗺 𝗦𝗮𝗹𝗲𝘀 𝗙𝘂𝗻𝗻𝗲𝗹.'"
],
"transition_phrases": [
"ECHTE Übergangssätze zwischen Absätzen",
"z.B. 'Und wisst ihr was?', 'Aber Moment...', 'Was das mit X zu tun hat?'"
],
"emotional_expressions": [
"Ausrufe und emotionale Marker",
"z.B. 'Halleluja!', 'Sorry to say!!', 'Galopp!!!!'"
],
"cta_phrases": [
"ECHTE Call-to-Action Formulierungen",
"z.B. 'Was denkt ihr?', 'Seid ihr dabei?', 'Lasst uns darüber sprechen.'"
],
"filler_expressions": [
"Typische Füllwörter und Ausdrücke",
"z.B. 'Ich meine...', 'Wisst ihr...', 'Ok, ok...'"
]
}},
"structure_templates": {{
"primary_structure": "Die häufigste Struktur beschreiben, z.B. 'Unicode-Hook → Persönliche Anekdote → Erkenntnis → Bullet Points → CTA'",
"template_examples": [
{{
"name": "Storytelling-Post",
"structure": ["Fetter Hook mit Zitat", "Flashback/Anekdote", "Erkenntnis/Lesson", "Praktische Tipps", "CTA-Frage"],
"example_post_index": 1
}},
{{
"name": "Insight-Post",
"structure": ["Provokante These", "Begründung", "Beispiel", "Handlungsaufforderung"],
"example_post_index": 2
}}
],
"typical_sentence_starters": [
"ECHTE Satzanfänge aus den Posts",
"z.B. 'Ich glaube, dass...', 'Was mir aufgefallen ist...', 'Das Verrückte ist...'"
],
"paragraph_transitions": [
"Wie werden Absätze eingeleitet?",
"z.B. 'Und...', 'Aber:', 'Das bedeutet:'"
]
}},
"tone_analysis": {{
"primary_tone": "Haupttonalität basierend auf den Posts",
"emotional_range": "Welche Emotionen werden angesprochen?",
"authenticity_markers": ["Was macht den Stil einzigartig?", "Erkennbare Merkmale"]
}},
"topic_patterns": {{
"main_topics": ["Hauptthemen aus den Posts"],
"content_pillars": ["Content-Säulen"],
"expertise_areas": ["Expertise-Bereiche"],
"expertise_level": "Anfänger/Fortgeschritten/Experte"
}},
"audience_insights": {{
"target_audience": "Wer wird angesprochen?",
"pain_points_addressed": ["Probleme die adressiert werden"],
"value_proposition": "Welchen Mehrwert bietet die Person?",
"industry_context": "Branchenkontext"
}},
"visual_patterns": {{
"emoji_usage": {{
"emojis": ["Liste der tatsächlich verwendeten Emojis"],
"placement": "Anfang/Ende/Inline/Zwischen Absätzen",
"frequency": "Selten/Mittel/Häufig - pro Post durchschnittlich X"
}},
"unicode_formatting": "Wird ✓, →, •, 𝗙𝗲𝘁𝘁 etc. verwendet? Wo?",
"structure_preferences": "Absätze/Listen/Einzeiler/Nummeriert"
}},
"content_strategy": {{
"hook_patterns": "Wie werden Posts KONKRET eröffnet? Beschreibung des Musters",
"cta_style": "Wie sehen die CTAs aus? Frage? Aufforderung? Keine?",
"storytelling_approach": "Persönliche Geschichten? Metaphern? Case Studies?",
"post_structure": "Hook → Body → CTA? Oder anders?"
}},
"best_performing_patterns": {{
"what_works": "Was machen die Top-Posts anders/besser?",
"successful_hooks": ["WÖRTLICHE Beispiel-Hooks aus Top-Posts"],
"engagement_drivers": ["Was treibt Engagement?"]
}}
}}
KRITISCH: Bei phrase_library und structure_templates müssen ECHTE, WÖRTLICHE Beispiele aus den Posts stehen! Keine generischen Beschreibungen!"""

630
src/agents/researcher.py Normal file
View File

@@ -0,0 +1,630 @@
"""Research agent using Perplexity."""
import json
import random
from datetime import datetime, timedelta
from typing import Dict, Any, List
from loguru import logger
from src.agents.base import BaseAgent
class ResearchAgent(BaseAgent):
"""Agent for researching new content topics using Perplexity."""
def __init__(self):
"""Initialize research agent."""
super().__init__("Researcher")
async def process(
self,
profile_analysis: Dict[str, Any],
existing_topics: List[str],
customer_data: Dict[str, Any],
example_posts: List[str] = None,
post_type: Any = None,
post_type_analysis: Dict[str, Any] = None
) -> Dict[str, Any]:
"""
Research new content topics.
Args:
profile_analysis: Profile analysis results
existing_topics: List of already covered topics
customer_data: Customer data (contains persona, style_guide, etc.)
example_posts: List of the person's actual posts for style reference
post_type: Optional PostType object for targeted research
post_type_analysis: Optional post type analysis for context
Returns:
Research results with suggested topics
"""
logger.info("Starting research for new content topics")
if post_type:
logger.info(f"Targeting research for post type: {post_type.name}")
# Extract key information from profile analysis
audience_insights = profile_analysis.get("audience_insights", {})
topic_patterns = profile_analysis.get("topic_patterns", {})
industry = audience_insights.get("industry_context", "Business")
target_audience = audience_insights.get("target_audience", "Professionals")
content_pillars = topic_patterns.get("content_pillars", [])
pain_points = audience_insights.get("pain_points_addressed", [])
value_proposition = audience_insights.get("value_proposition", "")
# Extract customer-specific data
persona = customer_data.get("persona", "") if customer_data else ""
# STEP 1: Use Perplexity for REAL internet research (has live data!)
logger.info("Step 1: Researching with Perplexity (live internet data)")
perplexity_prompt = self._get_perplexity_prompt(
industry=industry,
target_audience=target_audience,
content_pillars=content_pillars,
existing_topics=existing_topics,
pain_points=pain_points,
persona=persona
)
# Dynamic system prompt for variety
system_prompts = [
"Du bist ein investigativer Journalist. Finde die neuesten, spannendsten Entwicklungen mit harten Fakten.",
"Du bist ein Branchen-Analyst. Identifiziere aktuelle Trends und Marktbewegungen mit konkreten Daten.",
"Du bist ein Trend-Scout. Spüre auf, was diese Woche wirklich neu und relevant ist.",
"Du bist ein Research-Spezialist. Finde aktuelle Studien, Statistiken und News mit Quellenangaben."
]
raw_research = await self.call_perplexity(
system_prompt=random.choice(system_prompts),
user_prompt=perplexity_prompt,
model="sonar-pro"
)
logger.info("Step 2: Transforming research into personalized topic ideas")
# STEP 2: Transform raw research into PERSONALIZED topic suggestions
transform_prompt = self._get_transform_prompt(
raw_research=raw_research,
target_audience=target_audience,
persona=persona,
content_pillars=content_pillars,
example_posts=example_posts or [],
existing_topics=existing_topics,
post_type=post_type,
post_type_analysis=post_type_analysis
)
response = await self.call_openai(
system_prompt=self._get_topic_creator_system_prompt(),
user_prompt=transform_prompt,
model="gpt-4o",
temperature=0.7, # Higher for creative topic angles
response_format={"type": "json_object"}
)
# Parse JSON response
result = json.loads(response)
suggested_topics = result.get("topics", [])
# STEP 3: Ensure diversity - filter out similar topics
suggested_topics = self._ensure_diversity(suggested_topics)
# Parse research results
research_results = {
"raw_response": response,
"suggested_topics": suggested_topics,
"industry": industry,
"target_audience": target_audience
}
logger.info(f"Research completed with {len(research_results['suggested_topics'])} topic suggestions")
return research_results
def _get_topic_creator_system_prompt(self) -> str:
"""Get system prompt for transforming research into personalized topics."""
return """Du bist ein LinkedIn Content-Stratege, der aus Recherche-Ergebnissen KONKRETE, PERSONALISIERTE Themenvorschläge erstellt.
WICHTIG: Du erstellst KEINE Schlagzeilen oder News-Titel!
Du erstellst KONKRETE CONTENT-IDEEN mit:
- Einem klaren ANGLE (Perspektive/Blickwinkel)
- Einer konkreten HOOK-IDEE
- Einem NARRATIV das die Person erzählen könnte
Der Unterschied:
❌ SCHLECHT (Schlagzeile): "KI verändert den Arbeitsmarkt"
✅ GUT (Themenvorschlag): "Warum ich als [Rolle] plötzlich 50% meiner Zeit mit KI-Prompts verbringe - und was das für mein Team bedeutet"
❌ SCHLECHT: "Neue Studie zu Remote Work"
✅ GUT: "3 Erkenntnisse aus der Stanford Remote-Studie, die mich als Führungskraft überrascht haben"
❌ SCHLECHT: "Fachkräftemangel in der IT"
✅ GUT: "Unpopuläre Meinung: Wir haben keinen Fachkräftemangel - wir haben ein Ausbildungsproblem. Hier ist was ich damit meine..."
Deine Themenvorschläge müssen:
1. ZUR PERSON PASSEN - Klingt wie etwas das diese spezifische Person posten würde
2. EINEN KONKRETEN ANGLE HABEN - Nicht "über X schreiben" sondern "diesen spezifischen Aspekt von X aus dieser Perspektive beleuchten"
3. EINEN HOOK VORSCHLAGEN - Eine konkrete Idee wie der Post starten könnte
4. HINTERGRUND-INFOS LIEFERN - Fakten/Daten aus der Recherche die die Person nutzen kann
5. ABWECHSLUNGSREICH SEIN - Verschiedene Formate und Kategorien
Antworte als JSON."""
def _get_system_prompt(self) -> str:
"""Get system prompt for research (legacy, kept for compatibility)."""
return """Du bist ein hochspezialisierter Trend-Analyst und Content-Researcher.
Deine Mission ist es, aktuelle, hochrelevante Content-Themen für LinkedIn zu identifizieren.
Du sollst:
1. Aktuelle Trends, News und Diskussionen der letzten 7-14 Tage recherchieren
2. Themen finden, die für die spezifische Zielgruppe relevant sind
3. Verschiedene Kategorien abdecken:
- Aktuelle News & Studien
- Schmerzpunkt-Lösungen
- Konträre Trends (gegen Mainstream-Meinung)
- Emerging Topics
Für jedes Thema sollst du bereitstellen:
- Einen prägnanten Titel
- Den Kern-Fakt (mit Daten, Quellen, Beispielen)
- Warum es relevant ist für die Zielgruppe
- Die Kategorie
Fokussiere dich auf Themen, die:
- AKTUELL sind (letzte 1-2 Wochen)
- KONKRET sind (mit Daten/Fakten belegt)
- RELEVANT sind für die Zielgruppe
- UNIQUE sind (nicht bereits behandelt)
Gib deine Antwort als JSON zurück."""
def _get_user_prompt(
self,
industry: str,
target_audience: str,
content_pillars: List[str],
existing_topics: List[str],
pain_points: List[str] = None,
value_proposition: str = "",
persona: str = ""
) -> str:
"""Get user prompt for research."""
pillars_text = ", ".join(content_pillars) if content_pillars else "Verschiedene Business-Themen"
existing_text = ", ".join(existing_topics[:20]) if existing_topics else "Keine"
pain_points_text = ", ".join(pain_points) if pain_points else "Nicht spezifiziert"
# Build persona section if available
persona_section = ""
if persona:
persona_section = f"""
**PERSONA DER PERSON (WICHTIG - Themen müssen zu dieser Expertise passen!):**
{persona[:800]}
"""
return f"""Recherchiere aktuelle LinkedIn-Content-Themen für folgendes Profil:
**KONTEXT:**
- Branche: {industry}
- Zielgruppe: {target_audience}
- Content-Säulen: {pillars_text}
- Pain Points der Zielgruppe: {pain_points_text}
- Value Proposition: {value_proposition or 'Mehrwert für die Zielgruppe bieten'}
{persona_section}
**BEREITS BEHANDELTE THEMEN (diese NICHT vorschlagen):**
{existing_text}
**AUFGABE:**
Finde 5-7 verschiedene aktuelle Themen, die:
1. ZUR EXPERTISE/PERSONA der Person passen
2. Die PAIN POINTS der Zielgruppe addressieren
3. AUTHENTISCH von dieser Person kommen könnten
4. NICHT generisch oder beliebig sind
Kategorien:
1. **News-Flash**: Aktuelle Nachrichten, Studien oder Entwicklungen
2. **Schmerzpunkt-Löser**: Probleme/Diskussionen, die die Zielgruppe aktuell beschäftigen
3. **Konträrer Trend**: Entwicklungen, die gegen die herkömmliche Meinung verstoßen
4. **Emerging Topic**: Neue Trends, die gerade an Fahrt gewinnen
WICHTIG: Themen müssen zur Person passen! Ein Experte für {industry} würde keine generischen "Productivity-Tips" posten, sondern spezifische Insights aus seinem Fachgebiet.
Fokus auf deutsche/DACH-Region relevante Themen.
Gib deine Antwort im folgenden JSON-Format zurück:
{{
"topics": [
{{
"title": "Prägnanter Arbeitstitel (spezifisch, nicht generisch!)",
"category": "News-Flash / Schmerzpunkt-Löser / Konträrer Trend / Emerging Topic",
"fact": "Detaillierte Zusammenfassung mit Daten, Fakten, Beispielen - SPEZIFISCH für diese Branche",
"relevance": "Warum ist das für {target_audience} wichtig und warum sollte DIESE Person darüber schreiben?",
"source": "Quellenangaben (Studien, Artikel, Statistiken)"
}}
]
}}"""
def _get_perplexity_prompt(
self,
industry: str,
target_audience: str,
content_pillars: List[str],
existing_topics: List[str],
pain_points: List[str] = None,
persona: str = ""
) -> str:
"""Get prompt for Perplexity research (optimized for live internet search)."""
pillars_text = ", ".join(content_pillars) if content_pillars else "Business-Themen"
existing_text = ", ".join(existing_topics[:20]) if existing_topics else "Keine bisherigen Themen"
pain_points_text = ", ".join(pain_points) if pain_points else "Allgemeine Business-Probleme"
# Current date for time-specific searches
today = datetime.now()
date_str = today.strftime("%d. %B %Y")
week_ago = (today - timedelta(days=7)).strftime("%d. %B %Y")
persona_hint = ""
if persona:
persona_hint = f"\nEXPERTISE DER PERSON: {persona[:600]}\n"
# Randomize the research focus for variety
research_angles = [
{
"name": "Breaking News & Studien",
"focus": "Suche nach brandneuen Studien, Reports, Umfragen oder Nachrichten",
"examples": "Neue Statistiken, Forschungsergebnisse, Unternehmens-Announcements"
},
{
"name": "Kontroverse & Debatten",
"focus": "Suche nach aktuellen Kontroversen, Meinungsverschiedenheiten, heißen Diskussionen",
"examples": "Polarisierende Meinungen, Kritik an Trends, unerwartete Entwicklungen"
},
{
"name": "Technologie & Innovation",
"focus": "Suche nach neuen Tools, Technologien, Methoden die gerade aufkommen",
"examples": "Neue Software, AI-Entwicklungen, Prozess-Innovationen"
},
{
"name": "Markt & Wirtschaft",
"focus": "Suche nach wirtschaftlichen Entwicklungen, Marktveränderungen, Branchen-Shifts",
"examples": "Fusionen, Insolvenzen, Markteintritt, Regulierungen"
},
{
"name": "Menschen & Karriere",
"focus": "Suche nach Personalien, Karriere-Trends, Arbeitsmarkt-Entwicklungen",
"examples": "Führungswechsel, Hiring-Trends, Remote Work Updates, Skill-Demands"
},
{
"name": "Fails & Learnings",
"focus": "Suche nach öffentlichen Fehlern, Shitstorms, Lessons Learned",
"examples": "PR-Desaster, gescheiterte Launches, öffentliche Kritik"
}
]
# Pick 3-4 random angles for this research session
selected_angles = random.sample(research_angles, min(4, len(research_angles)))
angles_text = "\n".join([
f"- **{angle['name']}**: {angle['focus']} (z.B. {angle['examples']})"
for angle in selected_angles
])
# Random seed words for more variety
seed_variations = [
f"Was ist DIESE WOCHE ({week_ago} bis {date_str}) passiert in {industry}?",
f"Welche BREAKING NEWS gibt es HEUTE ({date_str}) oder diese Woche in {industry}?",
f"Was diskutiert die {industry}-Branche AKTUELL ({date_str})?",
f"Welche NEUEN Entwicklungen gibt es seit {week_ago} in {industry}?"
]
seed_question = random.choice(seed_variations)
return f"""AKTUELLES DATUM: {date_str}
{seed_question}
{persona_hint}
KONTEXT:
- Branche: {industry}
- Zielgruppe: {target_audience}
- Themen-Fokus: {pillars_text}
- Pain Points: {pain_points_text}
RECHERCHE-SCHWERPUNKTE FÜR DIESE SESSION:
{angles_text}
⛔ BEREITS BEHANDELTE THEMEN - NICHT NOCHMAL VORSCHLAGEN:
{existing_text}
=== DEINE AUFGABE ===
Recherchiere FAKTEN, DATEN und ENTWICKLUNGEN - keine fertigen Themenvorschläge!
Ich brauche ROHDATEN die ich dann in personalisierte Content-Ideen umwandeln kann.
Für jede Entwicklung/News sammle:
1. **Was genau ist passiert?** - Konkrete Fakten, nicht Interpretationen
2. **Zahlen & Daten** - Statistiken, Prozentsätze, Beträge, Veränderungen
3. **Wer ist beteiligt?** - Unternehmen, Personen, Organisationen
4. **Wann?** - Genaues Datum oder Zeitraum
5. **Quelle** - URL oder Publikationsname
6. **Kontext** - Warum ist das relevant? Was bedeutet es?
SUCHE NACH:
✅ Neue Studien/Reports mit konkreten Zahlen
✅ Unternehmens-Entscheidungen oder -Ankündigungen
✅ Marktveränderungen mit Daten
✅ Gesetzliche/Regulatorische Änderungen
✅ Kontroverse Aussagen von Branchenführern
✅ Überraschende Statistiken oder Trends
✅ Gescheiterte Projekte oder unerwartete Erfolge
FORMAT DEINER ANTWORT:
Liefere 8-10 verschiedene Entwicklungen/News mit möglichst vielen Fakten und Zahlen.
Formatiere sie klar und strukturiert.
QUALITÄTSKRITERIEN:
✅ AKTUALITÄT: Von dieser Woche oder letzter Woche
✅ KONKRETHEIT: Echte Zahlen, Namen, Daten (nicht "Experten sagen...")
✅ VERIFIZIERBARKEIT: Echte Quelle die man prüfen kann
✅ BRANCHENRELEVANZ: Spezifisch für {industry}
❌ VERMEIDE:
- Vage Aussagen ohne Daten ("KI wird wichtiger")
- Generische Trends ohne konkreten Aufhänger
- Alte News die jeder schon kennt
- Themen ohne verifizierbare Fakten"""
def _get_transform_prompt(
self,
raw_research: str,
target_audience: str,
persona: str,
content_pillars: List[str],
example_posts: List[str],
existing_topics: List[str],
post_type: Any = None,
post_type_analysis: Dict[str, Any] = None
) -> str:
"""Transform raw research into personalized, concrete topic suggestions."""
# Build example posts section
examples_section = ""
if example_posts:
examples_section = "\n\n=== SO SCHREIBT DIESE PERSON (Beispiel-Posts) ===\n"
for i, post in enumerate(example_posts[:5], 1):
post_preview = post[:600] + "..." if len(post) > 600 else post
examples_section += f"\n--- Beispiel {i} ---\n{post_preview}\n"
examples_section += "--- Ende Beispiele ---\n"
# Build pillars section
pillars_text = ", ".join(content_pillars[:5]) if content_pillars else "Keine spezifischen Säulen"
# Build existing topics section (to avoid)
existing_text = ", ".join(existing_topics[:15]) if existing_topics else "Keine"
# Build post type context section
post_type_section = ""
if post_type:
post_type_section = f"""
=== ZIEL-POST-TYP: {post_type.name} ===
{f"Beschreibung: {post_type.description}" if post_type.description else ""}
{f"Typische Hashtags: {', '.join(post_type.identifying_hashtags[:5])}" if post_type.identifying_hashtags else ""}
{f"Keywords: {', '.join(post_type.identifying_keywords[:10])}" if post_type.identifying_keywords else ""}
"""
if post_type.semantic_properties:
props = post_type.semantic_properties
if props.get("purpose"):
post_type_section += f"Zweck: {props['purpose']}\n"
if props.get("typical_tone"):
post_type_section += f"Tonalität: {props['typical_tone']}\n"
if props.get("target_audience"):
post_type_section += f"Zielgruppe: {props['target_audience']}\n"
if post_type_analysis and post_type_analysis.get("sufficient_data"):
post_type_section += "\n**Analyse-basierte Anforderungen:**\n"
if hooks := post_type_analysis.get("hooks"):
post_type_section += f"- Hook-Typen: {', '.join(hooks.get('hook_types', [])[:3])}\n"
if content := post_type_analysis.get("content_focus"):
post_type_section += f"- Hauptthemen: {', '.join(content.get('main_themes', [])[:3])}\n"
if content.get("target_emotion"):
post_type_section += f"- Ziel-Emotion: {content['target_emotion']}\n"
post_type_section += "\n**WICHTIG:** Alle Themenvorschläge müssen zu diesem Post-Typ passen!\n"
return f"""AUFGABE: Transformiere die Recherche-Ergebnisse in KONKRETE, PERSONALISIERTE Themenvorschläge.
{post_type_section}
=== RECHERCHE-ERGEBNISSE (Rohdaten) ===
{raw_research}
=== PERSON/EXPERTISE ===
{persona[:800] if persona else "Keine Persona definiert"}
=== CONTENT-SÄULEN DER PERSON ===
{pillars_text}
{examples_section}
=== BEREITS BEHANDELT (NICHT NOCHMAL!) ===
{existing_text}
=== DEINE AUFGABE ===
Erstelle 6-8 KONKRETE Themenvorschläge die:
1. ZU DIESER PERSON PASSEN - Basierend auf Expertise und Beispiel-Posts
2. EINEN KLAREN ANGLE HABEN - Nicht "über X schreiben" sondern eine spezifische Perspektive
3. FAKTEN AUS DER RECHERCHE NUTZEN - Konkrete Daten/Zahlen einbauen
4. ABWECHSLUNGSREICH SIND - Verschiedene Kategorien und Formate
KATEGORIEN (mindestens 3 verschiedene!):
- **Meinung/Take**: Deine Perspektive zu einem aktuellen Thema
- **Erfahrungsbericht**: "Was ich gelernt habe als..."
- **Konträr**: "Unpopuläre Meinung: ..."
- **How-To/Insight**: Konkrete Tipps basierend auf Daten
- **Story**: Persönliche Geschichte mit Business-Lesson
- **Analyse**: Daten/Trend analysiert durch deine Expertise-Brille
FORMAT DER THEMENVORSCHLÄGE:
{{
"topics": [
{{
"title": "Konkreter Thementitel (kein Schlagzeilen-Stil!)",
"category": "Meinung/Take | Erfahrungsbericht | Konträr | How-To/Insight | Story | Analyse",
"angle": "Der spezifische Blickwinkel/die Perspektive für diesen Post",
"hook_idea": "Konkrete Hook-Idee die zum Post passen würde (1-2 Sätze)",
"key_facts": ["Fakt 1 aus der Recherche", "Fakt 2 mit Zahlen", "Fakt 3"],
"why_this_person": "Warum passt dieses Thema zu DIESER Person und ihrer Expertise?",
"source": "Quellenangabe"
}}
]
}}
BEISPIEL EINES GUTEN THEMENVORSCHLAGS:
{{
"title": "Warum ich als Tech-Lead jetzt 30% meiner Zeit mit Prompt Engineering verbringe",
"category": "Erfahrungsbericht",
"angle": "Persönliche Erfahrung eines Tech-Leads mit der Veränderung seiner Rolle durch KI",
"hook_idea": "Vor einem Jahr habe ich Code geschrieben. Heute schreibe ich Prompts. Und ehrlich? Ich weiß noch nicht ob das gut oder schlecht ist.",
"key_facts": ["GitHub Copilot wird von 92% der Entwickler genutzt (Stack Overflow 2024)", "Durchschnittliche Zeitersparnis: 55%", "Aber: Code-Review-Zeit +40%"],
"why_this_person": "Als Tech-Lead hat die Person direkten Einblick in diese Veränderung und kann authentisch darüber berichten",
"source": "Stack Overflow Developer Survey 2024"
}}
WICHTIG:
- Jeder Vorschlag muss sich UNTERSCHEIDEN (anderer Angle, andere Kategorie)
- Keine generischen "Die Zukunft von X" Themen
- Hook-Ideen müssen zum Stil der Beispiel-Posts passen!
- Key Facts müssen aus der Recherche stammen (keine erfundenen Zahlen)"""
def _get_structure_prompt(
self,
raw_research: str,
target_audience: str,
persona: str = ""
) -> str:
"""Get prompt to structure Perplexity research into JSON (legacy)."""
return f"""Strukturiere die folgenden Recherche-Ergebnisse in ein sauberes JSON-Format.
RECHERCHE-ERGEBNISSE:
{raw_research}
AUFGABE:
Extrahiere die Themen und formatiere sie als JSON. Behalte ALLE Fakten, Quellen und Details bei.
Gib das Ergebnis in diesem Format zurück:
{{
"topics": [
{{
"title": "Prägnanter Titel des Themas",
"category": "News-Flash / Schmerzpunkt-Löser / Konträrer Trend / Emerging Topic",
"fact": "Die kompletten Fakten, Zahlen und Details aus der Recherche - NICHTS weglassen!",
"relevance": "Warum ist das für {target_audience} wichtig?",
"source": "Quellenangaben aus der Recherche"
}}
]
}}
WICHTIG:
- Behalte ALLE Fakten und Quellen aus der Recherche
- Erfinde NICHTS dazu
- Wenn etwas unklar ist, lass es weg
- Mindestens 5 Themen wenn vorhanden"""
def _ensure_diversity(self, topics: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
Ensure topic suggestions are diverse (different categories, angles).
Args:
topics: List of topic suggestions
Returns:
Filtered list with diverse topics
"""
if len(topics) <= 3:
return topics
# Track categories used
category_counts = {}
diverse_topics = []
for topic in topics:
category = topic.get("category", "Unknown")
# Allow max 2 topics per category
if category_counts.get(category, 0) < 2:
diverse_topics.append(topic)
category_counts[category] = category_counts.get(category, 0) + 1
# If we filtered too many, add back some
if len(diverse_topics) < 5 and len(topics) >= 5:
for topic in topics:
if topic not in diverse_topics:
diverse_topics.append(topic)
if len(diverse_topics) >= 6:
break
logger.info(f"Diversity check: {len(topics)} -> {len(diverse_topics)} topics, categories: {category_counts}")
return diverse_topics
def _extract_topics_from_response(self, response: str) -> List[Dict[str, Any]]:
"""
Extract structured topics from Perplexity response.
Args:
response: Raw response from Perplexity
Returns:
List of structured topic dictionaries
"""
topics = []
# Simple parsing - split by topic markers
sections = response.split("[TITEL]:")
for section in sections[1:]: # Skip first empty section
try:
# Extract title
title_end = section.find("[KATEGORIE]:")
if title_end == -1:
title_end = section.find("\n")
title = section[:title_end].strip()
# Extract category
category = ""
if "[KATEGORIE]:" in section:
cat_start = section.find("[KATEGORIE]:") + len("[KATEGORIE]:")
cat_end = section.find("[DER FAKT]:")
if cat_end == -1:
cat_end = section.find("\n", cat_start)
category = section[cat_start:cat_end].strip()
# Extract fact
fact = ""
if "[DER FAKT]:" in section:
fact_start = section.find("[DER FAKT]:") + len("[DER FAKT]:")
fact_end = section.find("[WARUM RELEVANT]:")
if fact_end == -1:
fact_end = section.find("[QUELLE]:")
if fact_end == -1:
fact_end = len(section)
fact = section[fact_start:fact_end].strip()
# Extract relevance
relevance = ""
if "[WARUM RELEVANT]:" in section:
rel_start = section.find("[WARUM RELEVANT]:") + len("[WARUM RELEVANT]:")
rel_end = section.find("[QUELLE]:")
if rel_end == -1:
rel_end = len(section)
relevance = section[rel_start:rel_end].strip()
if title and fact:
topics.append({
"title": title,
"category": category or "Allgemein",
"fact": fact,
"relevance": relevance,
"source": "perplexity_research"
})
except Exception as e:
logger.warning(f"Failed to parse topic section: {e}")
continue
return topics

View File

@@ -0,0 +1,129 @@
"""Topic extractor agent."""
import json
from typing import List, Dict, Any
from loguru import logger
from src.agents.base import BaseAgent
from src.database.models import LinkedInPost, Topic
class TopicExtractorAgent(BaseAgent):
"""Agent for extracting topics from LinkedIn posts."""
def __init__(self):
"""Initialize topic extractor agent."""
super().__init__("TopicExtractor")
async def process(self, posts: List[LinkedInPost], customer_id) -> List[Topic]:
"""
Extract topics from LinkedIn posts.
Args:
posts: List of LinkedIn posts
customer_id: Customer UUID (as UUID or string)
Returns:
List of extracted topics
"""
logger.info(f"Extracting topics from {len(posts)} posts")
# Prepare posts for analysis
posts_data = []
for idx, post in enumerate(posts[:30]): # Analyze up to 30 posts
posts_data.append({
"index": idx,
"post_id": str(post.id) if post.id else None,
"text": post.post_text[:500], # Limit text length
"date": str(post.post_date) if post.post_date else None
})
system_prompt = self._get_system_prompt()
user_prompt = self._get_user_prompt(posts_data)
response = await self.call_openai(
system_prompt=system_prompt,
user_prompt=user_prompt,
model="gpt-4o",
temperature=0.3,
response_format={"type": "json_object"}
)
# Parse response
result = json.loads(response)
topics_data = result.get("topics", [])
# Create Topic objects
topics = []
for topic_data in topics_data:
# Get post index from topic_data if available
post_index = topic_data.get("post_id")
extracted_from_post_id = None
# Map post index to actual post ID
if post_index is not None and isinstance(post_index, (int, str)):
try:
# Convert to int if it's a string representation
idx = int(post_index) if isinstance(post_index, str) else post_index
# Get the actual post from the posts list
if 0 <= idx < len(posts) and posts[idx].id:
extracted_from_post_id = posts[idx].id
except (ValueError, IndexError):
logger.warning(f"Could not map post index {post_index} to post ID")
topic = Topic(
customer_id=customer_id, # Will be handled by Pydantic
title=topic_data["title"],
description=topic_data.get("description"),
category=topic_data.get("category"),
extracted_from_post_id=extracted_from_post_id,
extraction_confidence=topic_data.get("confidence", 0.8)
)
topics.append(topic)
logger.info(f"Extracted {len(topics)} topics")
return topics
def _get_system_prompt(self) -> str:
"""Get system prompt for topic extraction."""
return """Du bist ein AI-Experte für Themenanalyse und Content-Kategorisierung.
Deine Aufgabe ist es, aus einer Liste von LinkedIn-Posts die Hauptthemen zu extrahieren.
Für jedes identifizierte Thema sollst du:
1. Ein prägnantes Titel geben
2. Eine kurze Beschreibung verfassen
3. Eine Kategorie zuweisen (z.B. "Technologie", "Strategie", "Personal Development", etc.)
4. Die Konfidenz angeben (0.0 - 1.0)
Wichtig:
- Fasse ähnliche Themen zusammen (z.B. "KI im Marketing" und "AI-Tools""KI & Automatisierung")
- Identifiziere übergeordnete Themen-Cluster
- Sei präzise und konkret
- Vermeide zu allgemeine Themen wie "Business" oder "Erfolg"
Gib deine Antwort als JSON zurück."""
def _get_user_prompt(self, posts_data: List[Dict[str, Any]]) -> str:
"""Get user prompt with posts data."""
posts_text = json.dumps(posts_data, indent=2, ensure_ascii=False)
return f"""Analysiere folgende LinkedIn-Posts und extrahiere die Hauptthemen:
{posts_text}
Gib deine Analyse im folgenden JSON-Format zurück:
{{
"topics": [
{{
"title": "Thementitel",
"description": "Kurze Beschreibung des Themas",
"category": "Kategorie",
"post_id": "ID des repräsentativen Posts (optional)",
"confidence": 0.9,
"frequency": "Wie oft kommt das Thema vor?"
}}
]
}}
Extrahiere 5-10 Hauptthemen."""

764
src/agents/writer.py Normal file
View File

@@ -0,0 +1,764 @@
"""Writer agent for creating LinkedIn posts."""
import asyncio
import json
import random
import re
from typing import Dict, Any, Optional, List
from loguru import logger
from src.agents.base import BaseAgent
from src.config import settings
class WriterAgent(BaseAgent):
"""Agent for writing LinkedIn posts based on profile analysis."""
def __init__(self):
"""Initialize writer agent."""
super().__init__("Writer")
async def process(
self,
topic: Dict[str, Any],
profile_analysis: Dict[str, Any],
feedback: Optional[str] = None,
previous_version: Optional[str] = None,
example_posts: Optional[List[str]] = None,
critic_result: Optional[Dict[str, Any]] = None,
learned_lessons: Optional[Dict[str, Any]] = None,
post_type: Any = None,
post_type_analysis: Optional[Dict[str, Any]] = None
) -> str:
"""
Write a LinkedIn post.
Args:
topic: Topic dictionary with title, fact, relevance
profile_analysis: Profile analysis results
feedback: Optional feedback from critic (text summary)
previous_version: Optional previous version of the post
example_posts: Optional list of real posts from the customer to use as style reference
critic_result: Optional full critic result with specific_changes
learned_lessons: Optional lessons learned from past critic feedback
post_type: Optional PostType object for type-specific writing
post_type_analysis: Optional analysis of the post type
Returns:
Written LinkedIn post
"""
if feedback and previous_version:
logger.info(f"Revising post based on critic feedback")
# For revisions, always use single draft (feedback is specific)
return await self._write_single_draft(
topic=topic,
profile_analysis=profile_analysis,
feedback=feedback,
previous_version=previous_version,
example_posts=example_posts,
critic_result=critic_result,
learned_lessons=learned_lessons,
post_type=post_type,
post_type_analysis=post_type_analysis
)
else:
logger.info(f"Writing initial post for topic: {topic.get('title', 'Unknown')}")
if post_type:
logger.info(f"Using post type: {post_type.name}")
# Select example posts - use semantic matching if enabled
selected_examples = self._select_example_posts(topic, example_posts, profile_analysis)
# Use Multi-Draft if enabled for initial posts
if settings.writer_multi_draft_enabled:
return await self._write_multi_draft(
topic=topic,
profile_analysis=profile_analysis,
example_posts=selected_examples,
learned_lessons=learned_lessons,
post_type=post_type,
post_type_analysis=post_type_analysis
)
else:
return await self._write_single_draft(
topic=topic,
profile_analysis=profile_analysis,
example_posts=selected_examples,
learned_lessons=learned_lessons,
post_type=post_type,
post_type_analysis=post_type_analysis
)
def _select_example_posts(
self,
topic: Dict[str, Any],
example_posts: Optional[List[str]],
profile_analysis: Dict[str, Any]
) -> List[str]:
"""
Select example posts - either semantically similar or random.
Args:
topic: The topic to write about
example_posts: All available example posts
profile_analysis: Profile analysis results
Returns:
Selected example posts (3-4 posts)
"""
if not example_posts or len(example_posts) == 0:
return []
if not settings.writer_semantic_matching_enabled:
# Fallback to random selection
num_examples = min(3, len(example_posts))
selected = random.sample(example_posts, num_examples)
logger.info(f"Using {len(selected)} random example posts")
return selected
# Semantic matching based on keywords
logger.info("Using semantic matching for example post selection")
# Extract keywords from topic
topic_text = f"{topic.get('title', '')} {topic.get('fact', '')} {topic.get('category', '')}".lower()
topic_keywords = self._extract_keywords(topic_text)
# Score each post by keyword overlap
scored_posts = []
for post in example_posts:
post_lower = post.lower()
score = 0
matched_keywords = []
for keyword in topic_keywords:
if keyword in post_lower:
score += 1
matched_keywords.append(keyword)
# Bonus for longer matches
score += len(matched_keywords) * 0.5
scored_posts.append({
"post": post,
"score": score,
"matched": matched_keywords
})
# Sort by score (highest first)
scored_posts.sort(key=lambda x: x["score"], reverse=True)
# Take top 2 by relevance + 1 random (for variety)
selected = []
# Top 2 most relevant
for item in scored_posts[:2]:
if item["score"] > 0:
selected.append(item["post"])
logger.debug(f"Selected post (score {item['score']:.1f}, keywords: {item['matched'][:3]})")
# Add 1 random post for variety (if not already selected)
remaining_posts = [p["post"] for p in scored_posts[2:] if p["post"] not in selected]
if remaining_posts and len(selected) < 3:
random_pick = random.choice(remaining_posts)
selected.append(random_pick)
logger.debug("Added 1 random post for variety")
# If we still don't have enough, fill with top scored
while len(selected) < 3 and len(selected) < len(example_posts):
for item in scored_posts:
if item["post"] not in selected:
selected.append(item["post"])
break
logger.info(f"Selected {len(selected)} example posts via semantic matching")
return selected
def _extract_keywords(self, text: str) -> List[str]:
"""Extract meaningful keywords from text."""
# Remove common stop words
stop_words = {
'der', 'die', 'das', 'und', 'in', 'zu', 'den', 'von', 'für', 'mit',
'auf', 'ist', 'im', 'sich', 'des', 'ein', 'eine', 'als', 'auch',
'es', 'an', 'werden', 'aus', 'er', 'hat', 'dass', 'sie', 'nach',
'wird', 'bei', 'einer', 'um', 'am', 'sind', 'noch', 'wie', 'einem',
'über', 'so', 'zum', 'kann', 'nur', 'sein', 'ich', 'nicht', 'was',
'oder', 'aber', 'wenn', 'ihre', 'man', 'the', 'and', 'to', 'of',
'a', 'is', 'that', 'it', 'for', 'on', 'are', 'with', 'be', 'this',
'was', 'have', 'from', 'your', 'you', 'we', 'our', 'mehr', 'neue',
'neuen', 'können', 'durch', 'diese', 'dieser', 'einem', 'einen'
}
# Split and clean
words = re.findall(r'\b[a-zäöüß]{3,}\b', text.lower())
keywords = [w for w in words if w not in stop_words and len(w) >= 4]
# Also extract compound words and important terms
important_terms = re.findall(r'\b[A-Z][a-zäöüß]+(?:[A-Z][a-zäöüß]+)*\b', text)
keywords.extend([t.lower() for t in important_terms if len(t) >= 4])
# Deduplicate while preserving order
seen = set()
unique_keywords = []
for kw in keywords:
if kw not in seen:
seen.add(kw)
unique_keywords.append(kw)
return unique_keywords[:15] # Limit to top 15 keywords
async def _write_multi_draft(
self,
topic: Dict[str, Any],
profile_analysis: Dict[str, Any],
example_posts: List[str],
learned_lessons: Optional[Dict[str, Any]] = None,
post_type: Any = None,
post_type_analysis: Optional[Dict[str, Any]] = None
) -> str:
"""
Generate multiple drafts and select the best one.
Args:
topic: Topic to write about
profile_analysis: Profile analysis results
example_posts: Example posts for style reference
learned_lessons: Lessons learned from past feedback
post_type: Optional PostType object
post_type_analysis: Optional post type analysis
Returns:
Best selected draft
"""
num_drafts = min(max(settings.writer_multi_draft_count, 2), 5) # Clamp between 2-5
logger.info(f"Generating {num_drafts} drafts for selection")
system_prompt = self._get_system_prompt(profile_analysis, example_posts, learned_lessons, post_type, post_type_analysis)
# Generate drafts in parallel with different temperatures/approaches
draft_configs = [
{"temperature": 0.5, "approach": "fokussiert"},
{"temperature": 0.7, "approach": "kreativ"},
{"temperature": 0.6, "approach": "ausgewogen"},
{"temperature": 0.8, "approach": "experimentell"},
{"temperature": 0.55, "approach": "präzise"},
][:num_drafts]
# Create draft tasks
async def generate_draft(config: Dict, draft_num: int) -> Dict[str, Any]:
user_prompt = self._get_user_prompt_for_draft(topic, draft_num, config["approach"])
try:
draft = await self.call_openai(
system_prompt=system_prompt,
user_prompt=user_prompt,
model="gpt-4o",
temperature=config["temperature"]
)
return {
"draft_num": draft_num,
"content": draft.strip(),
"approach": config["approach"],
"temperature": config["temperature"]
}
except Exception as e:
logger.error(f"Failed to generate draft {draft_num}: {e}")
return None
# Run drafts in parallel
tasks = [generate_draft(config, i + 1) for i, config in enumerate(draft_configs)]
results = await asyncio.gather(*tasks)
# Filter out failed drafts
drafts = [r for r in results if r is not None]
if not drafts:
raise ValueError("All draft generations failed")
if len(drafts) == 1:
logger.warning("Only one draft succeeded, using it directly")
return drafts[0]["content"]
logger.info(f"Generated {len(drafts)} drafts, now selecting best one")
# Select the best draft
best_draft = await self._select_best_draft(drafts, topic, profile_analysis)
return best_draft
def _get_user_prompt_for_draft(
self,
topic: Dict[str, Any],
draft_num: int,
approach: str
) -> str:
"""Get user prompt with slight variations for different drafts."""
# Different emphasis for each draft
emphasis_variations = {
1: "Fokussiere auf einen STARKEN, überraschenden Hook. Der erste Satz muss fesseln!",
2: "Fokussiere auf STORYTELLING. Baue eine kleine Geschichte oder Anekdote ein.",
3: "Fokussiere auf KONKRETEN MEHRWERT. Was lernt der Leser konkret?",
4: "Fokussiere auf EMOTION. Sprich Gefühle und persönliche Erfahrungen an.",
5: "Fokussiere auf PROVOKATION. Stelle eine These auf, die zum Nachdenken anregt.",
}
emphasis = emphasis_variations.get(draft_num, emphasis_variations[1])
# Build enhanced topic section with new fields
angle_section = ""
if topic.get('angle'):
angle_section = f"\n**ANGLE/PERSPEKTIVE:**\n{topic.get('angle')}\n"
hook_section = ""
if topic.get('hook_idea'):
hook_section = f"\n**HOOK-IDEE (als Inspiration):**\n\"{topic.get('hook_idea')}\"\n"
facts_section = ""
key_facts = topic.get('key_facts', [])
if key_facts and isinstance(key_facts, list) and len(key_facts) > 0:
facts_section = "\n**KEY FACTS (nutze diese!):**\n" + "\n".join([f"- {f}" for f in key_facts]) + "\n"
why_section = ""
if topic.get('why_this_person'):
why_section = f"\n**WARUM DU DARÜBER SCHREIBEN SOLLTEST:**\n{topic.get('why_this_person')}\n"
return f"""Schreibe einen LinkedIn-Post zu folgendem Thema:
**THEMA:** {topic.get('title', 'Unbekanntes Thema')}
**KATEGORIE:** {topic.get('category', 'Allgemein')}
{angle_section}{hook_section}
**KERN-FAKT / INHALT:**
{topic.get('fact', topic.get('description', ''))}
{facts_section}
**WARUM RELEVANT:**
{topic.get('relevance', 'Aktuelles Thema für die Zielgruppe')}
{why_section}
**DEIN ANSATZ FÜR DIESEN ENTWURF ({approach}):**
{emphasis}
**AUFGABE:**
Schreibe einen authentischen LinkedIn-Post, der:
1. Mit einem STARKEN, unerwarteten Hook beginnt (nutze die Hook-Idee als Inspiration, NICHT wörtlich!)
2. Den Fakt/das Thema aufgreift und Mehrwert bietet
3. Die Key Facts einbaut wo es passt
4. Eine persönliche Note oder Meinung enthält
5. Mit einem passenden CTA endet
WICHTIG:
- Vermeide KI-typische Formulierungen ("In der heutigen Zeit", "Tauchen Sie ein", etc.)
- Schreibe natürlich und menschlich
- Der Post soll SOFORT 85+ Punkte im Review erreichen
- Die Hook-Idee ist nur INSPIRATION - mach etwas Eigenes daraus!
Gib NUR den fertigen Post zurück."""
async def _select_best_draft(
self,
drafts: List[Dict[str, Any]],
topic: Dict[str, Any],
profile_analysis: Dict[str, Any]
) -> str:
"""
Use AI to select the best draft.
Args:
drafts: List of draft dictionaries
topic: The topic being written about
profile_analysis: Profile analysis for style reference
Returns:
Content of the best draft
"""
# Build comparison prompt
drafts_text = ""
for draft in drafts:
drafts_text += f"\n\n=== ENTWURF {draft['draft_num']} ({draft['approach']}) ===\n"
drafts_text += draft["content"]
drafts_text += "\n=== ENDE ENTWURF ==="
# Extract key style elements for comparison
writing_style = profile_analysis.get("writing_style", {})
linguistic = profile_analysis.get("linguistic_fingerprint", {})
phrase_library = profile_analysis.get("phrase_library", {})
selector_prompt = f"""Du bist ein erfahrener LinkedIn-Content-Editor. Wähle den BESTEN Entwurf aus.
**THEMA DES POSTS:**
{topic.get('title', 'Unbekannt')}
**STIL-ANFORDERUNGEN:**
- Tonalität: {writing_style.get('tone', 'Professionell')}
- Energie-Level: {linguistic.get('energy_level', 7)}/10
- Ansprache: {writing_style.get('form_of_address', 'Du')}
- Typische Hook-Phrasen: {', '.join(phrase_library.get('hook_phrases', [])[:3])}
**DIE ENTWÜRFE:**
{drafts_text}
**BEWERTUNGSKRITERIEN:**
1. **Hook-Qualität (30%):** Wie aufmerksamkeitsstark ist der erste Satz?
2. **Stil-Match (25%):** Wie gut passt der Entwurf zum beschriebenen Stil?
3. **Mehrwert (25%):** Wie viel konkreten Nutzen bietet der Post?
4. **Natürlichkeit (20%):** Wie authentisch und menschlich klingt er?
**AUFGABE:**
Analysiere jeden Entwurf kurz und wähle den besten. Antworte im JSON-Format:
{{
"analysis": [
{{"draft": 1, "hook_score": 8, "style_score": 7, "value_score": 8, "natural_score": 7, "total": 30, "notes": "Kurze Begründung"}},
...
],
"winner": 1,
"reason": "Kurze Begründung für die Wahl"
}}"""
response = await self.call_openai(
system_prompt="Du bist ein Content-Editor, der LinkedIn-Posts bewertet und den besten auswählt.",
user_prompt=selector_prompt,
model="gpt-4o-mini", # Use cheaper model for selection
temperature=0.2,
response_format={"type": "json_object"}
)
try:
result = json.loads(response)
winner_num = result.get("winner", 1)
reason = result.get("reason", "")
# Find the winning draft
winning_draft = next(
(d for d in drafts if d["draft_num"] == winner_num),
drafts[0] # Fallback to first draft
)
logger.info(f"Selected draft {winner_num} ({winning_draft['approach']}): {reason}")
return winning_draft["content"]
except (json.JSONDecodeError, KeyError) as e:
logger.warning(f"Failed to parse selector response, using first draft: {e}")
return drafts[0]["content"]
async def _write_single_draft(
self,
topic: Dict[str, Any],
profile_analysis: Dict[str, Any],
feedback: Optional[str] = None,
previous_version: Optional[str] = None,
example_posts: Optional[List[str]] = None,
critic_result: Optional[Dict[str, Any]] = None,
learned_lessons: Optional[Dict[str, Any]] = None,
post_type: Any = None,
post_type_analysis: Optional[Dict[str, Any]] = None
) -> str:
"""Write a single draft (original behavior)."""
# Select examples if not already selected
if example_posts is None:
example_posts = []
selected_examples = example_posts
if not feedback and not previous_version:
# Only select for initial posts, not revisions
if len(selected_examples) == 0:
pass # No examples available
elif len(selected_examples) > 3:
selected_examples = random.sample(selected_examples, 3)
system_prompt = self._get_system_prompt(profile_analysis, selected_examples, learned_lessons, post_type, post_type_analysis)
user_prompt = self._get_user_prompt(topic, feedback, previous_version, critic_result)
# Lower temperature for more consistent style matching
post = await self.call_openai(
system_prompt=system_prompt,
user_prompt=user_prompt,
model="gpt-4o",
temperature=0.6
)
logger.info("Post written successfully")
return post.strip()
def _get_system_prompt(
self,
profile_analysis: Dict[str, Any],
example_posts: List[str] = None,
learned_lessons: Optional[Dict[str, Any]] = None,
post_type: Any = None,
post_type_analysis: Optional[Dict[str, Any]] = None
) -> str:
"""Get system prompt for writer - orientiert an bewährten n8n-Prompts."""
# Extract key profile information
writing_style = profile_analysis.get("writing_style", {})
linguistic = profile_analysis.get("linguistic_fingerprint", {})
tone_analysis = profile_analysis.get("tone_analysis", {})
visual = profile_analysis.get("visual_patterns", {})
content_strategy = profile_analysis.get("content_strategy", {})
audience = profile_analysis.get("audience_insights", {})
phrase_library = profile_analysis.get("phrase_library", {})
structure_templates = profile_analysis.get("structure_templates", {})
# Build example posts section
examples_section = ""
if example_posts and len(example_posts) > 0:
examples_section = "\n\nREFERENZ-POSTS DER PERSON (Orientiere dich am Stil!):\n"
for i, post in enumerate(example_posts, 1):
post_text = post[:1800] + "..." if len(post) > 1800 else post
examples_section += f"\n--- Beispiel {i} ---\n{post_text}\n"
examples_section += "--- Ende Beispiele ---\n"
# Safe extraction of nested values
emoji_list = visual.get('emoji_usage', {}).get('emojis', ['🚀'])
emoji_str = ' '.join(emoji_list) if isinstance(emoji_list, list) else str(emoji_list)
sig_phrases = linguistic.get('signature_phrases', [])
narrative_anchors = linguistic.get('narrative_anchors', [])
narrative_str = ', '.join(narrative_anchors) if narrative_anchors else 'Storytelling'
pain_points = audience.get('pain_points_addressed', [])
pain_points_str = ', '.join(pain_points) if pain_points else 'Branchenspezifische Herausforderungen'
# Extract phrase library with variation instruction
hook_phrases = phrase_library.get('hook_phrases', [])
transition_phrases = phrase_library.get('transition_phrases', [])
emotional_expressions = phrase_library.get('emotional_expressions', [])
cta_phrases = phrase_library.get('cta_phrases', [])
filler_expressions = phrase_library.get('filler_expressions', [])
# Randomly select a subset of phrases for this post (variation!)
def select_phrases(phrases: list, max_count: int = 3) -> str:
if not phrases:
return "Keine verfügbar"
selected = random.sample(phrases, min(max_count, len(phrases)))
return '\n - '.join(selected)
# Extract structure templates
primary_structure = structure_templates.get('primary_structure', 'Hook → Body → CTA')
sentence_starters = structure_templates.get('typical_sentence_starters', [])
paragraph_transitions = structure_templates.get('paragraph_transitions', [])
# Build phrase library section
phrase_section = ""
if hook_phrases or emotional_expressions or cta_phrases:
phrase_section = f"""
2. PHRASEN-BIBLIOTHEK (Wähle passende aus - NICHT alle verwenden!):
HOOK-VORLAGEN (lass dich inspirieren, kopiere nicht 1:1):
- {select_phrases(hook_phrases, 4)}
ÜBERGANGS-PHRASEN (nutze 1-2 davon):
- {select_phrases(transition_phrases, 3)}
EMOTIONALE AUSDRÜCKE (nutze 1-2 passende):
- {select_phrases(emotional_expressions, 4)}
CTA-FORMULIERUNGEN (wähle eine passende):
- {select_phrases(cta_phrases, 3)}
FÜLL-AUSDRÜCKE (für natürlichen Flow):
- {select_phrases(filler_expressions, 3)}
SIGNATURE PHRASES (nutze maximal 1-2 ORGANISCH):
- {select_phrases(sig_phrases, 4)}
WICHTIG: Variiere! Nutze NICHT immer die gleichen Phrasen. Wähle die, die zum Thema passen.
"""
# Build structure section
structure_section = f"""
3. STRUKTUR-TEMPLATE:
Primäre Struktur: {primary_structure}
Typische Satzanfänge (nutze ähnliche):
- {select_phrases(sentence_starters, 4)}
Absatz-Übergänge:
- {select_phrases(paragraph_transitions, 3)}
"""
# Build lessons learned section (from past feedback)
lessons_section = ""
if learned_lessons and learned_lessons.get("lessons"):
lessons_section = "\n\n6. LESSONS LEARNED (aus vergangenen Posts - BEACHTE DIESE!):\n"
patterns = learned_lessons.get("patterns", {})
if patterns.get("posts_analyzed", 0) > 0:
lessons_section += f"\n(Basierend auf {patterns.get('posts_analyzed', 0)} analysierten Posts, Durchschnittsscore: {patterns.get('avg_score', 0):.0f}/100)\n"
for lesson in learned_lessons["lessons"]:
if lesson["type"] == "critical":
lessons_section += f"\n⚠️ KRITISCH - {lesson['message']}\n"
for item in lesson["items"]:
lessons_section += f"{item}\n"
elif lesson["type"] == "recurring":
lessons_section += f"\n📝 {lesson['message']}\n"
for item in lesson["items"]:
lessons_section += f"{item}\n"
lessons_section += "\nBerücksichtige diese Punkte PROAKTIV beim Schreiben!"
# Build post type section
post_type_section = ""
if post_type:
post_type_section = f"""
7. POST-TYP SPEZIFISCH: {post_type.name}
{f"Beschreibung: {post_type.description}" if post_type.description else ""}
"""
if post_type_analysis and post_type_analysis.get("sufficient_data"):
# Use the PostTypeAnalyzerAgent's helper method to generate the section
from src.agents.post_type_analyzer import PostTypeAnalyzerAgent
analyzer = PostTypeAnalyzerAgent()
type_guidelines = analyzer.get_writing_prompt_section(post_type_analysis)
if type_guidelines:
post_type_section += f"""
=== POST-TYP ANALYSE & RICHTLINIEN ===
{type_guidelines}
=== ENDE POST-TYP RICHTLINIEN ===
WICHTIG: Dieser Post MUSS den Mustern und Richtlinien dieses Post-Typs folgen!
"""
return f"""ROLLE: Du bist ein erstklassiger Ghostwriter für LinkedIn. Deine Aufgabe ist es, einen Post zu schreiben, der exakt so klingt wie der digitale Zwilling der beschriebenen Person. Du passt dich zu 100% an das bereitgestellte Profil an.
{examples_section}
1. STIL & ENERGIE:
Energie-Level (1-10): {linguistic.get('energy_level', 7)}
(WICHTIG: Passe die Intensität und Leidenschaft des Textes EXAKT an diesen Wert an. Bei 9-10 = hochemotional, bei 5-6 = sachlich-professionell)
Rhetorisches Shouting: {linguistic.get('shouting_usage', 'Dezent')}
(Nutze GROSSBUCHSTABEN für einzelne Wörter genau so wie hier beschrieben, um Emphase zu erzeugen - mach das für KEINE anderen Wörter!)
Tonalität: {tone_analysis.get('primary_tone', 'Professionell und authentisch')}
Ansprache (STRENGSTENS EINHALTEN): {writing_style.get('form_of_address', 'Du/Euch')}
Perspektive (STRENGSTENS EINHALTEN): {writing_style.get('perspective', 'Ich-Perspektive')}
Satz-Dynamik: {writing_style.get('sentence_dynamics', 'Mix aus kurzen und längeren Sätzen')}
Interpunktion: {linguistic.get('punctuation_patterns', 'Standard')}
Branche: {audience.get('industry_context', 'Business')}
Zielgruppe: {audience.get('target_audience', 'Professionals')}
{phrase_section}
{structure_section}
4. VISUELLE REGELN:
Unicode-Fettung: Nutze für den ersten Satz (Hook) fette Unicode-Zeichen (z.B. 𝗪𝗶𝗰𝗵𝘁𝗶𝗴𝗲𝗿 𝗦𝗮𝘁𝘇), sofern das zur Person passt: {visual.get('unicode_formatting', 'Fett für Hooks')}
Emoji-Logik: Verwende diese Emojis: {emoji_str}
Platzierung: {visual.get('emoji_usage', {}).get('placement', 'Ende')}
Häufigkeit: {visual.get('emoji_usage', {}).get('frequency', 'Mittel')}
Erzähl-Anker: Baue Elemente ein wie: {narrative_str}
(Falls 'PS-Zeilen', 'Dialoge' oder 'Flashbacks' genannt sind, integriere diese wenn es passt.)
Layout: {visual.get('structure_preferences', 'Kurze Absätze, mobil-optimiert')}
Länge: Ca. {writing_style.get('average_word_count', 300)} Wörter
CTA: Beende den Post mit einer Variante von: {content_strategy.get('cta_style', 'Interaktive Frage an die Community')}
5. GUARDRAILS (VERBOTE!):
Vermeide IMMER diese KI-typischen Muster:
- "In der heutigen Zeit", "Tauchen Sie ein", "Es ist kein Geheimnis"
- "Stellen Sie sich vor", "Lassen Sie uns", "Es ist wichtig zu verstehen"
- Gedankenstriche () zur Satzverbindung - nutze stattdessen Kommas oder Punkte
- Belehrende Formulierungen wenn die Person eine Ich-Perspektive nutzt
- Übertriebene Superlative ohne Substanz
- Zu perfekte, glatte Formulierungen - echte Menschen schreiben mit Ecken und Kanten
{lessons_section}
{post_type_section}
DEIN AUFTRAG: Schreibe den Post so, dass er für die Zielgruppe ({audience.get('target_audience', 'Professionals')}) einen klaren Mehrwert bietet und ihre Pain Points ({pain_points_str}) adressiert. Mach die Persönlichkeit des linguistischen Fingerabdrucks spürbar.
Beginne DIREKT mit dem Hook. Keine einleitenden Sätze, kein "Hier ist der Post"."""
def _get_user_prompt(
self,
topic: Dict[str, Any],
feedback: Optional[str] = None,
previous_version: Optional[str] = None,
critic_result: Optional[Dict[str, Any]] = None
) -> str:
"""Get user prompt for writer."""
if feedback and previous_version:
# Build specific changes section
specific_changes_text = ""
if critic_result and critic_result.get("specific_changes"):
specific_changes_text = "\n**KONKRETE ÄNDERUNGEN (FÜHRE DIESE EXAKT DURCH!):**\n"
for i, change in enumerate(critic_result["specific_changes"], 1):
specific_changes_text += f"\n{i}. ERSETZE:\n"
specific_changes_text += f" \"{change.get('original', '')}\"\n"
specific_changes_text += f" MIT:\n"
specific_changes_text += f" \"{change.get('replacement', '')}\"\n"
if change.get('reason'):
specific_changes_text += f" (Grund: {change.get('reason')})\n"
# Build improvements section
improvements_text = ""
if critic_result and critic_result.get("improvements"):
improvements_text = "\n**WEITERE VERBESSERUNGEN:**\n"
for imp in critic_result["improvements"]:
improvements_text += f"- {imp}\n"
# Revision mode with structured feedback
return f"""ÜBERARBEITE den Post basierend auf dem Kritiker-Feedback.
**VORHERIGE VERSION:**
{previous_version}
**AKTUELLER SCORE:** {critic_result.get('overall_score', 'N/A')}/100
**FEEDBACK:**
{feedback}
{specific_changes_text}
{improvements_text}
**DEINE AUFGABE:**
1. Führe die konkreten Änderungen EXAKT durch
2. Behalte alles bei was GUT bewertet wurde
3. Der überarbeitete Post soll mindestens 85 Punkte erreichen
Gib NUR den überarbeiteten Post zurück - keine Kommentare."""
else:
# Initial writing mode - enhanced with new topic fields
angle_section = ""
if topic.get('angle'):
angle_section = f"\n**ANGLE/PERSPEKTIVE:**\n{topic.get('angle')}\n"
hook_section = ""
if topic.get('hook_idea'):
hook_section = f"\n**HOOK-IDEE (als Inspiration):**\n\"{topic.get('hook_idea')}\"\n"
facts_section = ""
key_facts = topic.get('key_facts', [])
if key_facts and isinstance(key_facts, list) and len(key_facts) > 0:
facts_section = "\n**KEY FACTS (nutze diese!):**\n" + "\n".join([f"- {f}" for f in key_facts]) + "\n"
return f"""Schreibe einen LinkedIn-Post zu folgendem Thema:
**THEMA:** {topic.get('title', 'Unbekanntes Thema')}
**KATEGORIE:** {topic.get('category', 'Allgemein')}
{angle_section}{hook_section}
**KERN-FAKT / INHALT:**
{topic.get('fact', topic.get('description', ''))}
{facts_section}
**WARUM RELEVANT:**
{topic.get('relevance', 'Aktuelles Thema für die Zielgruppe')}
**AUFGABE:**
Schreibe einen authentischen LinkedIn-Post, der:
1. Mit einem STARKEN, unerwarteten Hook beginnt (nutze Hook-Idee als Inspiration!)
2. Den Fakt/das Thema aufgreift und Mehrwert bietet
3. Die Key Facts einbaut wo es passt
4. Eine persönliche Note oder Meinung enthält
5. Mit einem passenden CTA endet
WICHTIG:
- Vermeide KI-typische Formulierungen ("In der heutigen Zeit", "Tauchen Sie ein", etc.)
- Schreibe natürlich und menschlich
- Der Post soll SOFORT 85+ Punkte im Review erreichen
Gib NUR den fertigen Post zurück."""