aktueller stand
This commit is contained in:
20
src/agents/__init__.py
Normal file
20
src/agents/__init__.py
Normal 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
120
src/agents/base.py
Normal 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
276
src/agents/critic.py
Normal 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"""
|
||||
279
src/agents/post_classifier.py
Normal file
279
src/agents/post_classifier.py
Normal 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
|
||||
335
src/agents/post_type_analyzer.py
Normal file
335
src/agents/post_type_analyzer.py
Normal 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)
|
||||
300
src/agents/profile_analyzer.py
Normal file
300
src/agents/profile_analyzer.py
Normal 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
630
src/agents/researcher.py
Normal 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
|
||||
129
src/agents/topic_extractor.py
Normal file
129
src/agents/topic_extractor.py
Normal 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
764
src/agents/writer.py
Normal 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."""
|
||||
Reference in New Issue
Block a user