changed to claude
This commit is contained in:
@@ -5,6 +5,7 @@ pydantic-settings==2.3.0
|
|||||||
|
|
||||||
# AI & APIs
|
# AI & APIs
|
||||||
openai==1.54.0
|
openai==1.54.0
|
||||||
|
anthropic>=0.49.0
|
||||||
apify-client==1.7.0
|
apify-client==1.7.0
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
from urllib.parse import urlsplit, urlunsplit
|
||||||
|
from anthropic import AnthropicFoundry
|
||||||
from openai import OpenAI
|
from openai import OpenAI
|
||||||
import httpx
|
import httpx
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
@@ -12,6 +14,31 @@ from src.database.client import db
|
|||||||
|
|
||||||
class BaseAgent(ABC):
|
class BaseAgent(ABC):
|
||||||
"""Base class for all AI agents."""
|
"""Base class for all AI agents."""
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_azure_claude_base_url(base_url: str) -> str:
|
||||||
|
"""Normalize Azure Claude URL to the AnthropicFoundry base URL."""
|
||||||
|
raw = (base_url or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return raw
|
||||||
|
|
||||||
|
parsed = urlsplit(raw)
|
||||||
|
scheme = parsed.scheme or "https"
|
||||||
|
netloc = parsed.netloc or parsed.path
|
||||||
|
path = parsed.path if parsed.netloc else ""
|
||||||
|
path = (path or "").rstrip("/")
|
||||||
|
|
||||||
|
if path.endswith("/anthropic/v1/messages"):
|
||||||
|
normalized_path = path[: -len("/v1/messages")]
|
||||||
|
elif path.endswith("/anthropic"):
|
||||||
|
normalized_path = path
|
||||||
|
else:
|
||||||
|
for suffix in ("/openai/v1/chat/completions", "/openai/v1", "/openai", "/models"):
|
||||||
|
if path.endswith(suffix):
|
||||||
|
path = path[: -len(suffix)]
|
||||||
|
break
|
||||||
|
normalized_path = f"{path}/anthropic"
|
||||||
|
|
||||||
|
return urlunsplit((scheme, netloc, normalized_path, "", ""))
|
||||||
|
|
||||||
def __init__(self, name: str):
|
def __init__(self, name: str):
|
||||||
"""
|
"""
|
||||||
@@ -22,6 +49,17 @@ class BaseAgent(ABC):
|
|||||||
"""
|
"""
|
||||||
self.name = name
|
self.name = name
|
||||||
self.openai_client = OpenAI(api_key=settings.openai_api_key)
|
self.openai_client = OpenAI(api_key=settings.openai_api_key)
|
||||||
|
self.azure_claude_client: Optional[Any] = None
|
||||||
|
self.azure_claude_base_url: str = ""
|
||||||
|
if settings.azure_claude_api_key:
|
||||||
|
base_source = settings.azure_claude_base_url or settings.azure_claude_endpoint
|
||||||
|
if base_source:
|
||||||
|
self.azure_claude_base_url = self._normalize_azure_claude_base_url(base_source)
|
||||||
|
self.azure_claude_client = AnthropicFoundry(
|
||||||
|
api_key=settings.azure_claude_api_key,
|
||||||
|
base_url=self.azure_claude_base_url
|
||||||
|
)
|
||||||
|
logger.info(f"[{self.name}] Azure Claude base URL normalized to {self.azure_claude_base_url}")
|
||||||
self._usage_logs: List[Dict[str, Any]] = []
|
self._usage_logs: List[Dict[str, Any]] = []
|
||||||
self._user_id: Optional[str] = None
|
self._user_id: Optional[str] = None
|
||||||
self._company_id: Optional[str] = None
|
self._company_id: Optional[str] = None
|
||||||
@@ -141,6 +179,83 @@ class BaseAgent(ABC):
|
|||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
async def call_azure_claude(
|
||||||
|
self,
|
||||||
|
system_prompt: str,
|
||||||
|
user_prompt: str,
|
||||||
|
model: Optional[str] = None,
|
||||||
|
temperature: float = 0.7,
|
||||||
|
response_format: Optional[Dict[str, str]] = None
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Call Azure-hosted Claude via the Anthropic-compatible Azure endpoint.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
system_prompt: System message
|
||||||
|
user_prompt: User message
|
||||||
|
model: Human-readable model identifier for logging/pricing
|
||||||
|
temperature: Temperature for sampling
|
||||||
|
response_format: Optional structured response format
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Assistant's response
|
||||||
|
"""
|
||||||
|
if not self.azure_claude_client or not settings.azure_claude_api_key:
|
||||||
|
raise ValueError(
|
||||||
|
"Azure Claude is not configured. Set AZURE_CLAUDE_API_KEY and either "
|
||||||
|
"AZURE_CLAUDE_BASE_URL or AZURE_CLAUDE_ENDPOINT."
|
||||||
|
)
|
||||||
|
|
||||||
|
display_model = model or settings.azure_claude_model
|
||||||
|
deployment = settings.azure_claude_deployment or display_model
|
||||||
|
logger.info(f"[{self.name}] Calling Azure Claude ({display_model} via {deployment})")
|
||||||
|
|
||||||
|
kwargs: Dict[str, Any] = {
|
||||||
|
"model": deployment,
|
||||||
|
"system": system_prompt,
|
||||||
|
"messages": [
|
||||||
|
{"role": "user", "content": user_prompt}
|
||||||
|
],
|
||||||
|
"temperature": temperature,
|
||||||
|
"max_tokens": 4096
|
||||||
|
}
|
||||||
|
|
||||||
|
if response_format:
|
||||||
|
kwargs["system"] = (
|
||||||
|
system_prompt
|
||||||
|
+ "\n\nWICHTIG: Antworte mit einem einzigen gültigen JSON-Objekt und ohne zusätzliche Erklärung."
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await asyncio.to_thread(
|
||||||
|
self.azure_claude_client.messages.create,
|
||||||
|
**kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
content_blocks = getattr(response, "content", []) or []
|
||||||
|
text_parts = []
|
||||||
|
for block in content_blocks:
|
||||||
|
if hasattr(block, "text"):
|
||||||
|
text_parts.append(block.text or "")
|
||||||
|
elif isinstance(block, dict) and block.get("type") == "text":
|
||||||
|
text_parts.append(block.get("text", ""))
|
||||||
|
final_text = "\n".join(part for part in text_parts if part).strip()
|
||||||
|
logger.debug(f"[{self.name}] Received Azure Claude response (length: {len(final_text)})")
|
||||||
|
|
||||||
|
usage = getattr(response, "usage", None)
|
||||||
|
input_tokens = getattr(usage, "input_tokens", 0) if usage else 0
|
||||||
|
output_tokens = getattr(usage, "output_tokens", 0) if usage else 0
|
||||||
|
total_tokens = input_tokens + output_tokens
|
||||||
|
if total_tokens:
|
||||||
|
await self._log_usage(
|
||||||
|
provider="azure",
|
||||||
|
model=display_model,
|
||||||
|
prompt_tokens=input_tokens,
|
||||||
|
completion_tokens=output_tokens,
|
||||||
|
total_tokens=total_tokens
|
||||||
|
)
|
||||||
|
|
||||||
|
return final_text
|
||||||
|
|
||||||
async def call_perplexity(
|
async def call_perplexity(
|
||||||
self,
|
self,
|
||||||
system_prompt: str,
|
system_prompt: str,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class CriticAgent(BaseAgent):
|
|||||||
topic: Dict[str, Any],
|
topic: Dict[str, Any],
|
||||||
example_posts: Optional[List[str]] = None,
|
example_posts: Optional[List[str]] = None,
|
||||||
iteration: int = 1,
|
iteration: int = 1,
|
||||||
max_iterations: int = 3
|
max_iterations: int = 2
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Review a LinkedIn post and provide feedback.
|
Review a LinkedIn post and provide feedback.
|
||||||
@@ -57,7 +57,7 @@ class CriticAgent(BaseAgent):
|
|||||||
|
|
||||||
return result
|
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:
|
def _get_system_prompt(self, profile_analysis: Dict[str, Any], example_posts: Optional[List[str]] = None, iteration: int = 1, max_iterations: int = 2) -> str:
|
||||||
"""Get system prompt for critic - orientiert an bewährten n8n-Prompts."""
|
"""Get system prompt for critic - orientiert an bewährten n8n-Prompts."""
|
||||||
def _ensure_dict(value: Any) -> Dict[str, Any]:
|
def _ensure_dict(value: Any) -> Dict[str, Any]:
|
||||||
return value if isinstance(value, dict) else {}
|
return value if isinstance(value, dict) else {}
|
||||||
@@ -78,6 +78,7 @@ class CriticAgent(BaseAgent):
|
|||||||
structure_templates = profile_analysis.get("structure_templates", {})
|
structure_templates = profile_analysis.get("structure_templates", {})
|
||||||
structure_templates_dict = _ensure_dict(structure_templates)
|
structure_templates_dict = _ensure_dict(structure_templates)
|
||||||
audience_insights = _ensure_dict(profile_analysis.get("audience_insights", {}))
|
audience_insights = _ensure_dict(profile_analysis.get("audience_insights", {}))
|
||||||
|
visual_patterns = _ensure_dict(profile_analysis.get("visual_patterns", {}))
|
||||||
|
|
||||||
# Build example posts section for style comparison
|
# Build example posts section for style comparison
|
||||||
examples_section = ""
|
examples_section = ""
|
||||||
@@ -96,6 +97,8 @@ class CriticAgent(BaseAgent):
|
|||||||
hook_phrases = phrase_library.get('hook_phrases', [])
|
hook_phrases = phrase_library.get('hook_phrases', [])
|
||||||
emotional_expressions = phrase_library.get('emotional_expressions', [])
|
emotional_expressions = phrase_library.get('emotional_expressions', [])
|
||||||
cta_phrases = phrase_library.get('cta_phrases', [])
|
cta_phrases = phrase_library.get('cta_phrases', [])
|
||||||
|
emoji_usage = _ensure_dict(visual_patterns.get("emoji_usage", {}))
|
||||||
|
unicode_formatting = visual_patterns.get("unicode_formatting", "Keine klare Vorgabe")
|
||||||
|
|
||||||
# Extract structure info
|
# Extract structure info
|
||||||
if isinstance(structure_templates, list):
|
if isinstance(structure_templates, list):
|
||||||
@@ -129,7 +132,7 @@ ITERATION {iteration}/{max_iterations} - Fortschritt anerkennen:
|
|||||||
- Fokussiere auf verbleibende Verbesserungen
|
- Fokussiere auf verbleibende Verbesserungen
|
||||||
- Erwarteter Score-Bereich: 75-90 (wenn erste Kritik gut umgesetzt)"""
|
- 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.
|
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, typische KI-Muster aufweist oder inhaltlich unnötig schwächer wirkt.
|
||||||
{examples_section}
|
{examples_section}
|
||||||
{iteration_guidance}
|
{iteration_guidance}
|
||||||
|
|
||||||
@@ -142,12 +145,41 @@ Energie-Level: {linguistic.get('energy_level', 7)}/10 (1=sachlich, 10=explosiv)
|
|||||||
Signature Phrases: {sig_phrases_str}
|
Signature Phrases: {sig_phrases_str}
|
||||||
Tonalität: {tone_analysis.get('primary_tone', 'Professionell')}
|
Tonalität: {tone_analysis.get('primary_tone', 'Professionell')}
|
||||||
Erwartete Struktur: {primary_structure}
|
Erwartete Struktur: {primary_structure}
|
||||||
|
Unicode-Formatierung: {unicode_formatting}
|
||||||
|
Emoji-Frequenz: {emoji_usage.get('frequency', 'Mittel')}
|
||||||
|
Emoji-Platzierung: {emoji_usage.get('placement', 'Ende')}
|
||||||
|
|
||||||
PHRASEN-REFERENZ (Der Post sollte ÄHNLICHE Formulierungen nutzen - nicht identisch, aber im gleichen Stil):
|
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'}
|
- 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'}
|
- 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'}
|
- CTA-Stil Beispiele: {', '.join(cta_phrases[:2]) if cta_phrases else 'Keine verfügbar'}
|
||||||
|
|
||||||
|
KRITIK-PRIORITÄTEN (SEHR WICHTIG):
|
||||||
|
1. Bewahre Kernaussage, Logik und realen Mehrwert des Posts.
|
||||||
|
2. Prüfe erst danach Stil-Match und Authentizität.
|
||||||
|
3. Schlage Stiländerungen NUR vor, wenn sie den Inhalt mindestens gleich gut transportieren.
|
||||||
|
4. Bestrafe NICHT bloß deshalb, weil der Text anders formuliert ist als die Beispiele. Bestrafe nur echten Stilbruch.
|
||||||
|
5. Verwechsle "klar und gut geschrieben" nicht mit "KI". KI-Muster sind generische, austauschbare, formelhafte oder überglatte Formulierungen.
|
||||||
|
|
||||||
|
SO ERKENNST DU GUTES STIL-MATCH:
|
||||||
|
- gleiche Perspektive, gleiche soziale Distanz, ähnliche sprachliche Temperatur
|
||||||
|
- ähnlicher Rhythmus aus kurzen und längeren Sätzen
|
||||||
|
- ähnliche Hook-Mechanik, nicht derselbe Wortlaut
|
||||||
|
- ähnliche Übergänge, CTAs und emotionale Ausschläge
|
||||||
|
- ähnliche Direktheit, Reibung, Unperfektheit oder Reflexion wie in den echten Beispielen
|
||||||
|
|
||||||
|
SO ERKENNST DU SCHLECHTES STIL-MATCH:
|
||||||
|
- generische Business-Sprache statt persönlicher Stimme
|
||||||
|
- zu glatt, zu perfekt, zu werblich oder zu didaktisch
|
||||||
|
- künstlich eingebaute Signature Phrases, die wie Deko wirken
|
||||||
|
- korrekter Inhalt, aber falsches Tempo, falsche Haltung oder falsche soziale Distanz
|
||||||
|
|
||||||
|
ÄNDERUNGS-DISZIPLIN:
|
||||||
|
- Verlange keine stilistische Änderung, wenn sie nur Geschmackssache ist.
|
||||||
|
- Verlange keine emotionalere Formulierung, wenn die Person eigentlich nüchtern schreibt.
|
||||||
|
- Verlange keine knapperen Sätze, wenn die Person normalerweise ausschreibt und ausführt.
|
||||||
|
- Wenn ein Satz inhaltlich stark ist, aber stilistisch leicht off wirkt: minimale statt komplette Umformulierung.
|
||||||
|
|
||||||
|
|
||||||
CHIRURGISCHE KORREKTUR-REGELN (Prüfe diese Punkte!):
|
CHIRURGISCHE KORREKTUR-REGELN (Prüfe diese Punkte!):
|
||||||
|
|
||||||
@@ -186,6 +218,12 @@ CHIRURGISCHE KORREKTUR-REGELN (Prüfe diese Punkte!):
|
|||||||
- Der CTA sollte im gleichen Stil sein wie die CTA-Beispiele
|
- Der CTA sollte im gleichen Stil sein wie die CTA-Beispiele
|
||||||
- WICHTIG: Es geht um den STIL, nicht um wörtliches Kopieren!
|
- WICHTIG: Es geht um den STIL, nicht um wörtliches Kopieren!
|
||||||
|
|
||||||
|
8. FORMAT-COMPLIANCE:
|
||||||
|
- Prüfe, ob der Hook in der erwarteten Form formatiert ist (Unicode-Fettung ja/nein passend zum Profil)
|
||||||
|
- Prüfe, ob Emojis in Frequenz und Platzierung grob zum Profil passen
|
||||||
|
- Prüfe, ob Absatzbild und visuelle Struktur zum typischen Stil passen
|
||||||
|
- Markiere Formatverstöße nur dann, wenn sie wirklich vom Profil abweichen
|
||||||
|
|
||||||
|
|
||||||
BEWERTUNGSKRITERIEN (100 Punkte total):
|
BEWERTUNGSKRITERIEN (100 Punkte total):
|
||||||
|
|
||||||
@@ -215,6 +253,10 @@ BEWERTUNGSKRITERIEN (100 Punkte total):
|
|||||||
- Korrekte Formatierung
|
- Korrekte Formatierung
|
||||||
- Rechtschreibung und Grammatik (wird separat geprüft, hier nur grobe Fehler)
|
- Rechtschreibung und Grammatik (wird separat geprüft, hier nur grobe Fehler)
|
||||||
|
|
||||||
|
FORMAT-COMPLIANCE (separat, nicht Teil der 100 Punkte):
|
||||||
|
- Bewerte zusätzlich 0-100, wie gut der Post die Formatregeln der Person trifft
|
||||||
|
- Nutze diesen Wert für Feedback, aber NICHT zur künstlichen Abwertung von starkem Inhalt
|
||||||
|
|
||||||
|
|
||||||
SCORE-KALIBRIERUNG (WICHTIG - lies das genau!):
|
SCORE-KALIBRIERUNG (WICHTIG - lies das genau!):
|
||||||
|
|
||||||
@@ -252,7 +294,7 @@ WICHTIG FÜR DEIN FEEDBACK:
|
|||||||
|
|
||||||
Antworte als JSON."""
|
Antworte als JSON."""
|
||||||
|
|
||||||
def _get_user_prompt(self, post: str, topic: Dict[str, Any], iteration: int = 1, max_iterations: int = 3) -> str:
|
def _get_user_prompt(self, post: str, topic: Dict[str, Any], iteration: int = 1, max_iterations: int = 2) -> str:
|
||||||
"""Get user prompt for critic."""
|
"""Get user prompt for critic."""
|
||||||
iteration_note = ""
|
iteration_note = ""
|
||||||
if iteration > 1:
|
if iteration > 1:
|
||||||
@@ -287,6 +329,10 @@ Antworte im JSON-Format:
|
|||||||
"strengths": ["Stärke 1", "Stärke 2"],
|
"strengths": ["Stärke 1", "Stärke 2"],
|
||||||
"improvements": ["Verbesserung 1", "Verbesserung 2"],
|
"improvements": ["Verbesserung 1", "Verbesserung 2"],
|
||||||
"feedback": "Kurze Zusammenfassung",
|
"feedback": "Kurze Zusammenfassung",
|
||||||
|
"format_compliance": {{
|
||||||
|
"score": 0-100,
|
||||||
|
"issues": ["Format-Thema 1", "Format-Thema 2"]
|
||||||
|
}},
|
||||||
"specific_changes": [
|
"specific_changes": [
|
||||||
{{
|
{{
|
||||||
"original": "Exakter Text aus dem Post der geändert werden soll",
|
"original": "Exakter Text aus dem Post der geändert werden soll",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from typing import Dict, Any, List, Optional
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from src.agents.base import BaseAgent
|
from src.agents.base import BaseAgent
|
||||||
|
from src.config import settings
|
||||||
|
|
||||||
|
|
||||||
class QualityRefinerAgent(BaseAgent):
|
class QualityRefinerAgent(BaseAgent):
|
||||||
@@ -237,10 +238,10 @@ class QualityRefinerAgent(BaseAgent):
|
|||||||
system_prompt = self._get_final_polish_system_prompt(profile_analysis, example_posts)
|
system_prompt = self._get_final_polish_system_prompt(profile_analysis, example_posts)
|
||||||
user_prompt = self._get_final_polish_user_prompt(post, feedback)
|
user_prompt = self._get_final_polish_user_prompt(post, feedback)
|
||||||
|
|
||||||
polished_post = await self.call_openai(
|
polished_post = await self.call_azure_claude(
|
||||||
system_prompt=system_prompt,
|
system_prompt=system_prompt,
|
||||||
user_prompt=user_prompt,
|
user_prompt=user_prompt,
|
||||||
model="gpt-4o",
|
model=settings.azure_claude_model,
|
||||||
temperature=0.3 # Low temp for precise, minimal changes
|
temperature=0.3 # Low temp for precise, minimal changes
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -294,10 +295,10 @@ class QualityRefinerAgent(BaseAgent):
|
|||||||
system_prompt = self._get_smart_revision_system_prompt(profile_analysis, example_posts)
|
system_prompt = self._get_smart_revision_system_prompt(profile_analysis, example_posts)
|
||||||
user_prompt = self._get_smart_revision_user_prompt(post, feedback, quality_checks)
|
user_prompt = self._get_smart_revision_user_prompt(post, feedback, quality_checks)
|
||||||
|
|
||||||
revised_post = await self.call_openai(
|
revised_post = await self.call_azure_claude(
|
||||||
system_prompt=system_prompt,
|
system_prompt=system_prompt,
|
||||||
user_prompt=user_prompt,
|
user_prompt=user_prompt,
|
||||||
model="gpt-4o",
|
model=settings.azure_claude_model,
|
||||||
temperature=0.4 # Lower temp for more controlled revision
|
temperature=0.4 # Lower temp for more controlled revision
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -125,14 +125,14 @@ class WriterAgent(BaseAgent):
|
|||||||
profile_analysis: Profile analysis results
|
profile_analysis: Profile analysis results
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Selected example posts (3-4 posts)
|
Selected example posts (always max 2)
|
||||||
"""
|
"""
|
||||||
if not example_posts or len(example_posts) == 0:
|
if not example_posts or len(example_posts) == 0:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
if not settings.writer_semantic_matching_enabled:
|
if not settings.writer_semantic_matching_enabled:
|
||||||
# Fallback to random selection
|
# Fallback to random selection
|
||||||
num_examples = min(3, len(example_posts))
|
num_examples = min(2, len(example_posts))
|
||||||
selected = random.sample(example_posts, num_examples)
|
selected = random.sample(example_posts, num_examples)
|
||||||
logger.info(f"Using {len(selected)} random example posts")
|
logger.info(f"Using {len(selected)} random example posts")
|
||||||
return selected
|
return selected
|
||||||
@@ -168,7 +168,7 @@ class WriterAgent(BaseAgent):
|
|||||||
# Sort by score (highest first)
|
# Sort by score (highest first)
|
||||||
scored_posts.sort(key=lambda x: x["score"], reverse=True)
|
scored_posts.sort(key=lambda x: x["score"], reverse=True)
|
||||||
|
|
||||||
# Take top 2 by relevance + 1 random (for variety)
|
# Take top 2 by relevance
|
||||||
selected = []
|
selected = []
|
||||||
|
|
||||||
# Top 2 most relevant
|
# Top 2 most relevant
|
||||||
@@ -177,15 +177,8 @@ class WriterAgent(BaseAgent):
|
|||||||
selected.append(item["post"])
|
selected.append(item["post"])
|
||||||
logger.debug(f"Selected post (score {item['score']:.1f}, keywords: {item['matched'][:3]})")
|
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
|
# If we still don't have enough, fill with top scored
|
||||||
while len(selected) < 3 and len(selected) < len(example_posts):
|
while len(selected) < 2 and len(selected) < len(example_posts):
|
||||||
found = False
|
found = False
|
||||||
for item in scored_posts:
|
for item in scored_posts:
|
||||||
if item["post"] not in selected:
|
if item["post"] not in selected:
|
||||||
@@ -231,6 +224,33 @@ class WriterAgent(BaseAgent):
|
|||||||
|
|
||||||
return unique_keywords[:15] # Limit to top 15 keywords
|
return unique_keywords[:15] # Limit to top 15 keywords
|
||||||
|
|
||||||
|
def _parse_json_response(self, response: str) -> Dict[str, Any]:
|
||||||
|
"""Parse JSON robustly from model output, including fenced or prefixed responses."""
|
||||||
|
if not response:
|
||||||
|
raise json.JSONDecodeError("Empty response", "", 0)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return json.loads(response)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
cleaned = response.strip()
|
||||||
|
cleaned = re.sub(r"^```(?:json)?\s*", "", cleaned, flags=re.IGNORECASE)
|
||||||
|
cleaned = re.sub(r"\s*```$", "", cleaned)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return json.loads(cleaned)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
start = cleaned.find("{")
|
||||||
|
end = cleaned.rfind("}")
|
||||||
|
if start != -1 and end != -1 and end > start:
|
||||||
|
candidate = cleaned[start:end + 1]
|
||||||
|
return json.loads(candidate)
|
||||||
|
|
||||||
|
raise json.JSONDecodeError("No JSON object found", cleaned, 0)
|
||||||
|
|
||||||
async def _write_multi_draft(
|
async def _write_multi_draft(
|
||||||
self,
|
self,
|
||||||
topic: Dict[str, Any],
|
topic: Dict[str, Any],
|
||||||
@@ -283,10 +303,10 @@ class WriterAgent(BaseAgent):
|
|||||||
async def generate_draft(config: Dict, draft_num: int) -> Dict[str, Any]:
|
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"], user_thoughts, selected_hook)
|
user_prompt = self._get_user_prompt_for_draft(topic, draft_num, config["approach"], user_thoughts, selected_hook)
|
||||||
try:
|
try:
|
||||||
draft = await self.call_openai(
|
draft = await self.call_azure_claude(
|
||||||
system_prompt=system_prompt,
|
system_prompt=system_prompt,
|
||||||
user_prompt=user_prompt,
|
user_prompt=user_prompt,
|
||||||
model="gpt-4o",
|
model=settings.azure_claude_model,
|
||||||
temperature=config["temperature"]
|
temperature=config["temperature"]
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
@@ -522,10 +542,10 @@ Analysiere jeden Entwurf kurz und wähle den besten. Antworte im JSON-Format:
|
|||||||
"reason": "Kurze Begründung für die Wahl"
|
"reason": "Kurze Begründung für die Wahl"
|
||||||
}}"""
|
}}"""
|
||||||
|
|
||||||
response = await self.call_openai(
|
response = await self.call_azure_claude(
|
||||||
system_prompt="Du bist ein Content-Editor, der LinkedIn-Posts bewertet und den besten auswählt.",
|
system_prompt="Du bist ein Content-Editor, der LinkedIn-Posts bewertet und den besten auswählt.",
|
||||||
user_prompt=selector_prompt,
|
user_prompt=selector_prompt,
|
||||||
model="gpt-4o-mini", # Use cheaper model for selection
|
model=settings.azure_claude_model,
|
||||||
temperature=0.2,
|
temperature=0.2,
|
||||||
response_format={"type": "json_object"}
|
response_format={"type": "json_object"}
|
||||||
)
|
)
|
||||||
@@ -574,8 +594,10 @@ Analysiere jeden Entwurf kurz und wähle den besten. Antworte im JSON-Format:
|
|||||||
# Only select for initial posts, not revisions
|
# Only select for initial posts, not revisions
|
||||||
if len(selected_examples) == 0:
|
if len(selected_examples) == 0:
|
||||||
pass # No examples available
|
pass # No examples available
|
||||||
elif len(selected_examples) > 3:
|
elif len(selected_examples) > 2:
|
||||||
selected_examples = random.sample(selected_examples, 3)
|
selected_examples = self._select_example_posts(topic, selected_examples, profile_analysis)
|
||||||
|
elif len(selected_examples) > 2:
|
||||||
|
selected_examples = self._select_example_posts(topic, selected_examples, profile_analysis)
|
||||||
|
|
||||||
system_prompt = self._get_compact_system_prompt(
|
system_prompt = self._get_compact_system_prompt(
|
||||||
profile_analysis=profile_analysis,
|
profile_analysis=profile_analysis,
|
||||||
@@ -586,10 +608,10 @@ Analysiere jeden Entwurf kurz und wähle den besten. Antworte im JSON-Format:
|
|||||||
user_prompt = self._get_user_prompt(topic, feedback, previous_version, critic_result, user_thoughts, selected_hook)
|
user_prompt = self._get_user_prompt(topic, feedback, previous_version, critic_result, user_thoughts, selected_hook)
|
||||||
|
|
||||||
# OPTIMIERT: Niedrigere Temperature (0.5 statt 0.6) für konsistenteren Stil
|
# OPTIMIERT: Niedrigere Temperature (0.5 statt 0.6) für konsistenteren Stil
|
||||||
post = await self.call_openai(
|
post = await self.call_azure_claude(
|
||||||
system_prompt=system_prompt,
|
system_prompt=system_prompt,
|
||||||
user_prompt=user_prompt,
|
user_prompt=user_prompt,
|
||||||
model="gpt-4o",
|
model=settings.azure_claude_model,
|
||||||
temperature=0.5
|
temperature=0.5
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -608,6 +630,8 @@ Analysiere jeden Entwurf kurz und wähle den besten. Antworte im JSON-Format:
|
|||||||
structure_templates = _ensure_dict(profile_analysis.get("structure_templates", {}))
|
structure_templates = _ensure_dict(profile_analysis.get("structure_templates", {}))
|
||||||
visual = _ensure_dict(profile_analysis.get("visual_patterns", {}))
|
visual = _ensure_dict(profile_analysis.get("visual_patterns", {}))
|
||||||
audience = _ensure_dict(profile_analysis.get("audience_insights", {}))
|
audience = _ensure_dict(profile_analysis.get("audience_insights", {}))
|
||||||
|
content_strategy = _ensure_dict(profile_analysis.get("content_strategy", {}))
|
||||||
|
ngram_patterns = _ensure_dict(profile_analysis.get("ngram_patterns", {}))
|
||||||
|
|
||||||
def _top(items, n):
|
def _top(items, n):
|
||||||
if not isinstance(items, list):
|
if not isinstance(items, list):
|
||||||
@@ -616,9 +640,15 @@ Analysiere jeden Entwurf kurz und wähle den besten. Antworte im JSON-Format:
|
|||||||
|
|
||||||
hook_phrases = _top(phrase_library.get("hook_phrases", []), 3)
|
hook_phrases = _top(phrase_library.get("hook_phrases", []), 3)
|
||||||
cta_phrases = _top(phrase_library.get("cta_phrases", []), 2)
|
cta_phrases = _top(phrase_library.get("cta_phrases", []), 2)
|
||||||
|
transition_phrases = _top(phrase_library.get("transition_phrases", []), 3)
|
||||||
|
emotional_expressions = _top(phrase_library.get("emotional_expressions", []), 3)
|
||||||
signature_phrases = _top(linguistic.get("signature_phrases", []), 3)
|
signature_phrases = _top(linguistic.get("signature_phrases", []), 3)
|
||||||
|
narrative_anchors = _top(linguistic.get("narrative_anchors", []), 3)
|
||||||
|
typical_bigrams = _top(ngram_patterns.get("typical_bigrams", []), 4)
|
||||||
|
typical_trigrams = _top(ngram_patterns.get("typical_trigrams", []), 3)
|
||||||
sentence_starters = _top(structure_templates.get("typical_sentence_starters", []), 3)
|
sentence_starters = _top(structure_templates.get("typical_sentence_starters", []), 3)
|
||||||
paragraph_transitions = _top(structure_templates.get("paragraph_transitions", []), 2)
|
paragraph_transitions = _top(structure_templates.get("paragraph_transitions", []), 2)
|
||||||
|
pain_points = _top(audience.get("pain_points_addressed", []), 3)
|
||||||
|
|
||||||
emoji_usage = visual.get("emoji_usage", {})
|
emoji_usage = visual.get("emoji_usage", {})
|
||||||
emoji_list = emoji_usage.get("emojis", [])
|
emoji_list = emoji_usage.get("emojis", [])
|
||||||
@@ -630,13 +660,51 @@ Analysiere jeden Entwurf kurz und wähle den besten. Antworte im JSON-Format:
|
|||||||
- Ton: {tone_analysis.get('primary_tone', writing_style.get('tone', 'Professionell'))}
|
- Ton: {tone_analysis.get('primary_tone', writing_style.get('tone', 'Professionell'))}
|
||||||
- Energie: {linguistic.get('energy_level', 7)}/10
|
- Energie: {linguistic.get('energy_level', 7)}/10
|
||||||
- Satz-Dynamik: {writing_style.get('sentence_dynamics', 'Mix')}
|
- Satz-Dynamik: {writing_style.get('sentence_dynamics', 'Mix')}
|
||||||
|
- Interpunktion: {linguistic.get('punctuation_patterns', 'Standard')}
|
||||||
|
- Durchschnittslänge: ca. {writing_style.get('average_word_count', 300)} Wörter
|
||||||
- Hook-Stil Beispiele: {', '.join(hook_phrases) if hook_phrases else 'keine'}
|
- Hook-Stil Beispiele: {', '.join(hook_phrases) if hook_phrases else 'keine'}
|
||||||
- CTA-Stil Beispiele: {', '.join(cta_phrases) if cta_phrases else 'keine'}
|
- CTA-Stil Beispiele: {', '.join(cta_phrases) if cta_phrases else 'keine'}
|
||||||
|
- Übergangsphrasen: {', '.join(transition_phrases) if transition_phrases else 'keine'}
|
||||||
|
- Emotionale Ausdrücke: {', '.join(emotional_expressions) if emotional_expressions else 'keine'}
|
||||||
- Signature Phrases: {', '.join(signature_phrases) if signature_phrases else 'keine'}
|
- Signature Phrases: {', '.join(signature_phrases) if signature_phrases else 'keine'}
|
||||||
|
- Narrative Anker: {', '.join(narrative_anchors) if narrative_anchors else 'keine'}
|
||||||
|
- Typische Bigramme: {', '.join(typical_bigrams) if typical_bigrams else 'keine'}
|
||||||
|
- Typische Trigramme: {', '.join(typical_trigrams) if typical_trigrams else 'keine'}
|
||||||
- Typische Satzanfänge: {', '.join(sentence_starters) if sentence_starters else 'keine'}
|
- Typische Satzanfänge: {', '.join(sentence_starters) if sentence_starters else 'keine'}
|
||||||
- Übergänge: {', '.join(paragraph_transitions) if paragraph_transitions else 'keine'}
|
- Übergänge: {', '.join(paragraph_transitions) if paragraph_transitions else 'keine'}
|
||||||
- Emojis: {emoji_str or 'keine'} | Platzierung: {emoji_usage.get('placement', 'Ende')} | Frequenz: {emoji_usage.get('frequency', 'Mittel')}
|
- Emojis: {emoji_str or 'keine'} | Platzierung: {emoji_usage.get('placement', 'Ende')} | Frequenz: {emoji_usage.get('frequency', 'Mittel')}
|
||||||
- Zielgruppe: {audience.get('target_audience', 'Professionals')} | Branche: {audience.get('industry_context', 'Business')}
|
- Zielgruppe: {audience.get('target_audience', 'Professionals')} | Branche: {audience.get('industry_context', 'Business')}
|
||||||
|
- Pain Points: {', '.join(pain_points) if pain_points else 'keine'}
|
||||||
|
- CTA-Logik: {content_strategy.get('cta_style', 'Frage oder Gesprächseinstieg')}
|
||||||
|
- Story-Ansatz: {content_strategy.get('storytelling_approach', 'persönlich und konkret')}
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _build_format_card(self, profile_analysis: Dict[str, Any]) -> str:
|
||||||
|
"""Build a deterministic format card for rules better enforced by prompting and post-processing."""
|
||||||
|
def _ensure_dict(value):
|
||||||
|
return value if isinstance(value, dict) else {}
|
||||||
|
|
||||||
|
visual = _ensure_dict(profile_analysis.get("visual_patterns", {}))
|
||||||
|
emoji_usage = _ensure_dict(visual.get("emoji_usage", {}))
|
||||||
|
unicode_formatting = str(visual.get("unicode_formatting", "") or "")
|
||||||
|
structure_preferences = str(visual.get("structure_preferences", "Kurze Absätze, mobil-optimiert") or "Kurze Absätze, mobil-optimiert")
|
||||||
|
|
||||||
|
hook_bold = "auto"
|
||||||
|
unicode_lower = unicode_formatting.lower()
|
||||||
|
if any(token in unicode_lower for token in ["kein fett", "ohne fett", "nie fett", "keine fettung"]):
|
||||||
|
hook_bold = "verboten"
|
||||||
|
elif any(token in unicode_lower for token in ["fett", "unicode"]):
|
||||||
|
hook_bold = "erforderlich"
|
||||||
|
|
||||||
|
return f"""FORMAT CARD (verbindlich):
|
||||||
|
- Hook-Fettung: {hook_bold}
|
||||||
|
- Unicode-Hinweis: {unicode_formatting or 'keine klare Vorgabe'}
|
||||||
|
- Markdown-Fettung: verboten
|
||||||
|
- Trennlinien: verboten
|
||||||
|
- Emoji-Frequenz: {emoji_usage.get('frequency', 'Mittel')}
|
||||||
|
- Emoji-Platzierung: {emoji_usage.get('placement', 'Ende')}
|
||||||
|
- Layout: {structure_preferences}
|
||||||
|
- Absatzstil: mobil-optimiert, klare Zeilenumbrüche
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def _get_compact_system_prompt(
|
def _get_compact_system_prompt(
|
||||||
@@ -648,13 +716,14 @@ Analysiere jeden Entwurf kurz und wähle den besten. Antworte im JSON-Format:
|
|||||||
) -> str:
|
) -> str:
|
||||||
"""Short, high-signal system prompt to enforce style without overload."""
|
"""Short, high-signal system prompt to enforce style without overload."""
|
||||||
style_card = self._build_style_card(profile_analysis)
|
style_card = self._build_style_card(profile_analysis)
|
||||||
|
format_card = self._build_format_card(profile_analysis)
|
||||||
|
|
||||||
examples_section = ""
|
examples_section = ""
|
||||||
if example_posts:
|
if example_posts:
|
||||||
sample = example_posts[:2]
|
sample = example_posts[:2]
|
||||||
examples_section = "\nECHTE BEISPIELE (nur Stil, nicht kopieren):\n"
|
examples_section = "\nECHTE BEISPIELE DIESER KATEGORIE (nur Stil, nicht kopieren):\n"
|
||||||
for i, post in enumerate(sample, 1):
|
for i, post in enumerate(sample, 1):
|
||||||
post_text = post[:800] + "..." if len(post) > 800 else post
|
post_text = post[:1400] + "..." if len(post) > 1400 else post
|
||||||
examples_section += f"\n--- Beispiel {i} ---\n{post_text}\n"
|
examples_section += f"\n--- Beispiel {i} ---\n{post_text}\n"
|
||||||
|
|
||||||
strategy_section = ""
|
strategy_section = ""
|
||||||
@@ -685,11 +754,35 @@ UNTERNEHMENSKONTEXT ({weight_note}):
|
|||||||
|
|
||||||
return f"""Du bist Ghostwriter für LinkedIn. Schreibe einen Post, der exakt wie die Person klingt.
|
return f"""Du bist Ghostwriter für LinkedIn. Schreibe einen Post, der exakt wie die Person klingt.
|
||||||
{style_card}
|
{style_card}
|
||||||
|
{format_card}
|
||||||
{strategy_section}
|
{strategy_section}
|
||||||
{examples_section}
|
{examples_section}
|
||||||
|
ARBEITSWEISE:
|
||||||
|
- Trenne Stil und Inhalt sauber: Inhalt kommt aus Topic, Content-Plan und user_thoughts. Stil kommt aus Style Card und Beispielen.
|
||||||
|
- Übernimm NICHT die Fakten, Themen oder Beispiele aus den Referenzposts. Übernimm nur Rhythmus, Satzlängen, sprachliche Temperatur, Übergänge, Hook-Logik und CTA-Muster.
|
||||||
|
- Schreibe zuerst die Kernaussage sauber und logisch. Ziehe erst danach den Stil darüber.
|
||||||
|
- Wenn Stil und Klarheit kollidieren, bevorzuge klare Aussage und baue den Stil in Hook, Sprachfluss, Übergängen und CTA ein.
|
||||||
|
|
||||||
|
STIL-ZIELE:
|
||||||
|
- Die Person soll beim Lesen sagen: "So würde ich das schreiben."
|
||||||
|
- Imitiere bevorzugt diese Ebenen: 1. Perspektive und Ansprache, 2. Hook-Muster, 3. Satzrhythmus, 4. typische Übergänge, 5. emotionale Färbung, 6. CTA-Muster.
|
||||||
|
- Nutze Signature Phrases, typische Wortkombinationen und emotionale Ausdrücke nur punktuell. Wenige präzise Treffer sind besser als sichtbares Overfitting.
|
||||||
|
- Falls die Beispiele eher roh, direkt, knapp oder kantig klingen, glätte den Text NICHT künstlich.
|
||||||
|
- Falls die Beispiele eher reflektiert, ruhig oder analytisch sind, vermeide künstliche Dramatik.
|
||||||
|
|
||||||
|
INHALTS-SCHUTZ:
|
||||||
|
- Kernaussage, Argumentationslogik und Mehrwert dürfen durch die Stil-Imitation NICHT schwächer werden.
|
||||||
|
- Keine metaphorischen Ausschmückungen, wenn sie den Punkt unklarer machen.
|
||||||
|
- Keine Hook-Spielereien, wenn dadurch die eigentliche Aussage verwässert.
|
||||||
|
- Jede Formulierung muss sowohl stilistisch passend als auch inhaltlich sinnvoll sein.
|
||||||
|
|
||||||
REGELN:
|
REGELN:
|
||||||
- Inhalt strikt an den Content-Plan halten, keine neuen Fakten erfinden
|
- Inhalt strikt an den Content-Plan halten, keine neuen Fakten erfinden
|
||||||
- Perspektive und Ansprache strikt einhalten
|
- Perspektive und Ansprache strikt einhalten
|
||||||
|
- FORMAT CARD strikt einhalten, auch wenn das Modell stilistisch etwas anderes bevorzugen würde
|
||||||
|
- Hook, Struktur, Übergänge und CTA am Profil und den zwei Kategorie-Beispielen ausrichten
|
||||||
|
- Nutze typische Wortkombinationen und narrative Anker organisch, nie mechanisch
|
||||||
|
- Schmerzpunkte der Zielgruppe konkret adressieren
|
||||||
- Keine KI-Phrasen ("In der heutigen Zeit", "Stellen Sie sich vor", etc.)
|
- Keine KI-Phrasen ("In der heutigen Zeit", "Stellen Sie sich vor", etc.)
|
||||||
- Keine Markdown-Fettung (kein **), keine Trennlinien (---)
|
- Keine Markdown-Fettung (kein **), keine Trennlinien (---)
|
||||||
- Ausgabe: Nur der fertige Post"""
|
- Ausgabe: Nur der fertige Post"""
|
||||||
@@ -1283,34 +1376,54 @@ Gib NUR den fertigen Post zurück."""
|
|||||||
if hook_phrases:
|
if hook_phrases:
|
||||||
example_hooks = "\n\nBeispiel-Hooks dieser Person (zur Inspiration):\n" + "\n".join([f"- {h}" for h in hook_phrases[:5]])
|
example_hooks = "\n\nBeispiel-Hooks dieser Person (zur Inspiration):\n" + "\n".join([f"- {h}" for h in hook_phrases[:5]])
|
||||||
|
|
||||||
system_prompt = f"""Du bist ein Hook-Spezialist für LinkedIn Posts. Deine Aufgabe ist es, 4 verschiedene, aufmerksamkeitsstarke Hooks zu generieren.
|
anchor_terms = self._extract_hook_anchor_terms(topic, user_thoughts)
|
||||||
|
anchor_terms_text = ", ".join(anchor_terms[:10]) if anchor_terms else "keine"
|
||||||
|
format_card = self._build_format_card(profile_analysis)
|
||||||
|
|
||||||
|
system_prompt = f"""Du bist ein Hook-Spezialist für LinkedIn Posts. Deine Aufgabe ist es, HOCHWERTIGE Hooks zu generieren, die konkret, sinnvoll und stiltreu sind.
|
||||||
|
|
||||||
STIL DER PERSON:
|
STIL DER PERSON:
|
||||||
- Tonalität: {tone}
|
- Tonalität: {tone}
|
||||||
- Energie-Level: {energy}/10 (höher = emotionaler, niedriger = sachlicher)
|
- Energie-Level: {energy}/10 (höher = emotionaler, niedriger = sachlicher)
|
||||||
- Ansprache: {address}
|
- Ansprache: {address}
|
||||||
{example_hooks}
|
{example_hooks}
|
||||||
|
{format_card}
|
||||||
|
|
||||||
GENERIERE 4 VERSCHIEDENE HOOKS:
|
NICHT VERHANDELBAR:
|
||||||
|
- Jeder Hook muss auf dem tatsächlichen Inhalt basieren, nicht auf leerer Aufmerksamkeit.
|
||||||
|
- Kein generischer Einstieg. Kein austauschbares "Die meisten unterschätzen...", "Was wäre wenn...", "X verändert alles", wenn das nicht konkret begründet ist.
|
||||||
|
- Kein Cliffhanger ohne klare inhaltliche Verbindung.
|
||||||
|
- Kein Fakt behaupten, der im Input nicht enthalten ist.
|
||||||
|
- Wenn ein Hook provokant ist, muss die Provokation logisch aus dem Thema ableitbar sein.
|
||||||
|
- Wenn ein Hook storytelling ist, muss er wie ein glaubwürdiger Einstieg in die Perspektive der Person wirken.
|
||||||
|
- Der Hook muss auch OHNE den Rest des Posts Sinn ergeben.
|
||||||
|
|
||||||
|
GENERIERE 6 VERSCHIEDENE HOOKS:
|
||||||
1. **Provokant** - Eine kontroverse These oder überraschende Aussage
|
1. **Provokant** - Eine kontroverse These oder überraschende Aussage
|
||||||
2. **Storytelling** - Beginn einer persönlichen Geschichte oder Anekdote
|
2. **Storytelling** - Beginn einer persönlichen Geschichte oder Anekdote
|
||||||
3. **Fakten-basiert** - Eine überraschende Statistik oder Fakt
|
3. **Fakten-basiert** - Eine überraschende Statistik oder Fakt
|
||||||
4. **Neugier-weckend** - Eine Frage oder ein Cliffhanger
|
4. **Neugier-weckend** - Eine Frage oder ein Cliffhanger
|
||||||
|
5. **Pointiert** - Eine präzise, starke Beobachtung
|
||||||
|
6. **Reflektiert** - Eine nachdenkliche, glaubwürdige Einordnung
|
||||||
|
|
||||||
REGELN:
|
REGELN:
|
||||||
- Jeder Hook sollte 1-2 Sätze lang sein
|
- Jeder Hook sollte 1-2 Sätze lang sein
|
||||||
- Hooks müssen zum Energie-Level und Ton der Person passen
|
- Hooks müssen zum Energie-Level und Ton der Person passen
|
||||||
- Keine KI-typischen Phrasen ("In der heutigen Zeit", "Stellen Sie sich vor")
|
- Keine KI-typischen Phrasen ("In der heutigen Zeit", "Stellen Sie sich vor")
|
||||||
- Die Hooks sollen SOFORT Aufmerksamkeit erregen
|
- Die Hooks sollen SOFORT Aufmerksamkeit erregen, aber nur mit echter Substanz
|
||||||
- Bei Energie 8+ darf es emotional/leidenschaftlich sein
|
- Bei Energie 8+ darf es emotional/leidenschaftlich sein
|
||||||
- Bei Energie 5-7 eher sachlich-professionell
|
- Bei Energie 5-7 eher sachlich-professionell
|
||||||
|
- Nutze nach Möglichkeit mindestens einen dieser inhaltlichen Anker: {anchor_terms_text}
|
||||||
|
- Wenn Hook-Fettung laut Format Card erforderlich ist, formatiere nur den Hook selbst passend. Kein Markdown.
|
||||||
|
|
||||||
Antworte im JSON-Format:
|
Antworte im JSON-Format:
|
||||||
{{"hooks": [
|
{{"hooks": [
|
||||||
{{"hook": "Der Hook-Text hier", "style": "Provokant"}},
|
{{"hook": "Der Hook-Text hier", "style": "Provokant", "anchor": "welcher konkrete Fakt oder Gedanke dahinter steckt"}},
|
||||||
{{"hook": "Der Hook-Text hier", "style": "Storytelling"}},
|
{{"hook": "Der Hook-Text hier", "style": "Storytelling", "anchor": "welcher konkrete Fakt oder Gedanke dahinter steckt"}},
|
||||||
{{"hook": "Der Hook-Text hier", "style": "Fakten-basiert"}},
|
{{"hook": "Der Hook-Text hier", "style": "Fakten-basiert", "anchor": "welcher konkrete Fakt oder Gedanke dahinter steckt"}},
|
||||||
{{"hook": "Der Hook-Text hier", "style": "Neugier-weckend"}}
|
{{"hook": "Der Hook-Text hier", "style": "Neugier-weckend", "anchor": "welcher konkrete Fakt oder Gedanke dahinter steckt"}},
|
||||||
|
{{"hook": "Der Hook-Text hier", "style": "Pointiert", "anchor": "welcher konkrete Fakt oder Gedanke dahinter steckt"}},
|
||||||
|
{{"hook": "Der Hook-Text hier", "style": "Reflektiert", "anchor": "welcher konkrete Fakt oder Gedanke dahinter steckt"}}
|
||||||
]}}"""
|
]}}"""
|
||||||
|
|
||||||
# Build user prompt
|
# Build user prompt
|
||||||
@@ -1334,7 +1447,7 @@ Antworte im JSON-Format:
|
|||||||
if topic.get('key_points') and isinstance(topic.get('key_points'), list):
|
if topic.get('key_points') and isinstance(topic.get('key_points'), list):
|
||||||
content_block += "\n\nKERNPUNKTE:\n" + "\n".join([f"- {p}" for p in topic.get('key_points', [])])
|
content_block += "\n\nKERNPUNKTE:\n" + "\n".join([f"- {p}" for p in topic.get('key_points', [])])
|
||||||
|
|
||||||
user_prompt = f"""Generiere 4 Hooks für dieses Thema:
|
user_prompt = f"""Generiere 6 Hooks für dieses Thema:
|
||||||
|
|
||||||
THEMA: {topic.get('title', 'Unbekanntes Thema')}
|
THEMA: {topic.get('title', 'Unbekanntes Thema')}
|
||||||
|
|
||||||
@@ -1344,31 +1457,150 @@ KERN-FAKT/INHALT:
|
|||||||
{content_block}
|
{content_block}
|
||||||
{thoughts_section}{post_type_section}
|
{thoughts_section}{post_type_section}
|
||||||
|
|
||||||
Generiere jetzt die 4 verschiedenen Hooks im JSON-Format."""
|
WICHTIGE ANKERBEGRIFFE:
|
||||||
|
{anchor_terms_text}
|
||||||
|
|
||||||
response = await self.call_openai(
|
Generiere jetzt die 6 verschiedenen Hooks im JSON-Format."""
|
||||||
|
|
||||||
|
response = await self.call_azure_claude(
|
||||||
system_prompt=system_prompt,
|
system_prompt=system_prompt,
|
||||||
user_prompt=user_prompt,
|
user_prompt=user_prompt,
|
||||||
model="gpt-4o-mini",
|
model=settings.azure_claude_model,
|
||||||
temperature=0.8,
|
temperature=0.6,
|
||||||
response_format={"type": "json_object"}
|
response_format={"type": "json_object"}
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = json.loads(response)
|
result = self._parse_json_response(response)
|
||||||
hooks = result.get("hooks", [])
|
hooks = result.get("hooks", [])
|
||||||
|
hooks = self._select_best_hooks(hooks, topic, user_thoughts)
|
||||||
|
if len(hooks) < 4:
|
||||||
|
raise ValueError("Too few valid hooks after filtering")
|
||||||
logger.info(f"Generated {len(hooks)} hooks successfully")
|
logger.info(f"Generated {len(hooks)} hooks successfully")
|
||||||
return hooks
|
return hooks
|
||||||
except (json.JSONDecodeError, KeyError) as e:
|
except (json.JSONDecodeError, KeyError, ValueError) as e:
|
||||||
logger.error(f"Failed to parse hooks response: {e}")
|
logger.error(f"Failed to parse hooks response: {e}")
|
||||||
# Return fallback hooks
|
title = str(topic.get("title", "dieser Entwicklung") or "dieser Entwicklung")
|
||||||
|
fact = str(topic.get("fact", "dieser Punkt") or "dieser Punkt")
|
||||||
return [
|
return [
|
||||||
{"hook": f"Was wäre, wenn {topic.get('title', 'dieses Thema')} alles verändert?", "style": "Neugier-weckend"},
|
{"hook": f"An {title} ist für mich nicht die Schlagzeile entscheidend. Sondern die Verschiebung dahinter.", "style": "Pointiert"},
|
||||||
{"hook": f"Letzte Woche habe ich etwas über {topic.get('title', 'dieses Thema')} gelernt, das mich nicht mehr loslässt.", "style": "Storytelling"},
|
{"hook": f"Mich interessiert an {title} weniger der Hype als die Frage, was sich dadurch real verändert.", "style": "Reflektiert"},
|
||||||
{"hook": f"Die meisten unterschätzen {topic.get('title', 'dieses Thema')}. Ein Fehler.", "style": "Provokant"},
|
{"hook": f"Wenn {fact} stimmt, ist das deutlich mehr als nur eine Randnotiz.", "style": "Fakten-basiert"},
|
||||||
{"hook": f"Eine Zahl hat mich diese Woche überrascht: {topic.get('fact', 'Ein überraschender Fakt')}.", "style": "Fakten-basiert"}
|
{"hook": f"Die spannende Frage ist nicht, ob {title} relevant ist. Sondern was daraus jetzt konkret folgt.", "style": "Neugier-weckend"}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def _extract_hook_anchor_terms(self, topic: Dict[str, Any], user_thoughts: str = "") -> List[str]:
|
||||||
|
"""Extract concrete anchor terms so hooks stay tied to the actual topic."""
|
||||||
|
candidates: List[str] = []
|
||||||
|
|
||||||
|
def add_text(value: Any):
|
||||||
|
if isinstance(value, str) and value.strip():
|
||||||
|
candidates.append(value.strip())
|
||||||
|
elif isinstance(value, list):
|
||||||
|
for item in value[:8]:
|
||||||
|
if isinstance(item, str) and item.strip():
|
||||||
|
candidates.append(item.strip())
|
||||||
|
|
||||||
|
add_text(topic.get("title"))
|
||||||
|
add_text(topic.get("fact"))
|
||||||
|
add_text(topic.get("summary"))
|
||||||
|
add_text(topic.get("extended_summary"))
|
||||||
|
add_text(topic.get("key_points"))
|
||||||
|
add_text(topic.get("key_facts"))
|
||||||
|
add_text(user_thoughts)
|
||||||
|
|
||||||
|
blob = " ".join(candidates).lower()
|
||||||
|
words = re.findall(r"\b[a-zA-ZäöüÄÖÜß0-9][a-zA-ZäöüÄÖÜß0-9\-/]{2,}\b", blob)
|
||||||
|
stop_words = {
|
||||||
|
"der", "die", "das", "und", "oder", "aber", "eine", "einer", "einem", "einen",
|
||||||
|
"thema", "heute", "diese", "dieser", "dieses", "wurde", "werden", "kann", "sind",
|
||||||
|
"nicht", "mehr", "noch", "auch", "eine", "einen", "über", "unter", "durch", "from",
|
||||||
|
"with", "that", "this", "have", "your", "about", "into", "their", "what"
|
||||||
|
}
|
||||||
|
result: List[str] = []
|
||||||
|
seen = set()
|
||||||
|
for word in words:
|
||||||
|
if len(word) < 4 or word in stop_words:
|
||||||
|
continue
|
||||||
|
if word not in seen:
|
||||||
|
seen.add(word)
|
||||||
|
result.append(word)
|
||||||
|
return result[:15]
|
||||||
|
|
||||||
|
def _score_hook_candidate(self, hook_item: Dict[str, Any], anchor_terms: List[str]) -> float:
|
||||||
|
"""Score hooks for specificity and topical grounding."""
|
||||||
|
hook = str(hook_item.get("hook", "") or "").strip()
|
||||||
|
anchor = str(hook_item.get("anchor", "") or "").strip().lower()
|
||||||
|
style = str(hook_item.get("style", "") or "").strip()
|
||||||
|
hook_lower = hook.lower()
|
||||||
|
|
||||||
|
if not hook:
|
||||||
|
return -100.0
|
||||||
|
|
||||||
|
score = 0.0
|
||||||
|
|
||||||
|
generic_penalties = [
|
||||||
|
"die meisten unterschätzen",
|
||||||
|
"was wäre wenn",
|
||||||
|
"verändert alles",
|
||||||
|
"niemand spricht darüber",
|
||||||
|
"lass uns",
|
||||||
|
"stellen sie sich vor",
|
||||||
|
"in der heutigen zeit",
|
||||||
|
"es ist kein geheimnis",
|
||||||
|
]
|
||||||
|
for phrase in generic_penalties:
|
||||||
|
if phrase in hook_lower:
|
||||||
|
score -= 4.0
|
||||||
|
|
||||||
|
overlap = 0
|
||||||
|
for term in anchor_terms:
|
||||||
|
if term in hook_lower or term in anchor:
|
||||||
|
overlap += 1
|
||||||
|
score += min(overlap, 3) * 2.5
|
||||||
|
|
||||||
|
if "?" in hook:
|
||||||
|
score += 0.5
|
||||||
|
if len(hook) < 18:
|
||||||
|
score -= 2.0
|
||||||
|
if len(hook) > 220:
|
||||||
|
score -= 2.0
|
||||||
|
if hook.count(".") > 2:
|
||||||
|
score -= 1.5
|
||||||
|
|
||||||
|
if style in {"Provokant", "Storytelling", "Fakten-basiert", "Neugier-weckend", "Pointiert", "Reflektiert"}:
|
||||||
|
score += 0.5
|
||||||
|
|
||||||
|
return score
|
||||||
|
|
||||||
|
def _select_best_hooks(self, hooks: List[Dict[str, Any]], topic: Dict[str, Any], user_thoughts: str = "") -> List[Dict[str, str]]:
|
||||||
|
"""Filter and rank generated hooks so only grounded, non-generic hooks remain."""
|
||||||
|
if not isinstance(hooks, list):
|
||||||
|
return []
|
||||||
|
|
||||||
|
anchor_terms = self._extract_hook_anchor_terms(topic, user_thoughts)
|
||||||
|
scored: List[Dict[str, Any]] = []
|
||||||
|
seen_hooks = set()
|
||||||
|
|
||||||
|
for item in hooks:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
hook = str(item.get("hook", "") or "").strip()
|
||||||
|
style = str(item.get("style", "") or "").strip() or "Hook"
|
||||||
|
if not hook:
|
||||||
|
continue
|
||||||
|
|
||||||
|
dedupe_key = re.sub(r"\s+", " ", hook.lower())
|
||||||
|
if dedupe_key in seen_hooks:
|
||||||
|
continue
|
||||||
|
seen_hooks.add(dedupe_key)
|
||||||
|
|
||||||
|
score = self._score_hook_candidate(item, anchor_terms)
|
||||||
|
scored.append({"hook": hook, "style": style, "score": score})
|
||||||
|
|
||||||
|
scored.sort(key=lambda x: x["score"], reverse=True)
|
||||||
|
return [{"hook": item["hook"], "style": item["style"]} for item in scored[:4]]
|
||||||
|
|
||||||
async def generate_improvement_suggestions(
|
async def generate_improvement_suggestions(
|
||||||
self,
|
self,
|
||||||
post_content: str,
|
post_content: str,
|
||||||
@@ -1444,10 +1676,10 @@ Antworte im JSON-Format:
|
|||||||
|
|
||||||
Generiere 4 kurze, spezifische Verbesserungsvorschläge basierend auf dem Feedback und Profil."""
|
Generiere 4 kurze, spezifische Verbesserungsvorschläge basierend auf dem Feedback und Profil."""
|
||||||
|
|
||||||
response = await self.call_openai(
|
response = await self.call_azure_claude(
|
||||||
system_prompt=system_prompt,
|
system_prompt=system_prompt,
|
||||||
user_prompt=user_prompt,
|
user_prompt=user_prompt,
|
||||||
model="gpt-4o-mini",
|
model=settings.azure_claude_model,
|
||||||
temperature=0.7,
|
temperature=0.7,
|
||||||
response_format={"type": "json_object"}
|
response_format={"type": "json_object"}
|
||||||
)
|
)
|
||||||
@@ -1511,10 +1743,10 @@ ANZUWENDENDE VERBESSERUNG:
|
|||||||
|
|
||||||
Schreibe jetzt den überarbeiteten Post:"""
|
Schreibe jetzt den überarbeiteten Post:"""
|
||||||
|
|
||||||
response = await self.call_openai(
|
response = await self.call_azure_claude(
|
||||||
system_prompt=system_prompt,
|
system_prompt=system_prompt,
|
||||||
user_prompt=user_prompt,
|
user_prompt=user_prompt,
|
||||||
model="gpt-4o-mini",
|
model=settings.azure_claude_model,
|
||||||
temperature=0.5
|
temperature=0.5
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ class Settings(BaseSettings):
|
|||||||
openai_api_key: str
|
openai_api_key: str
|
||||||
perplexity_api_key: str
|
perplexity_api_key: str
|
||||||
apify_api_key: str
|
apify_api_key: str
|
||||||
|
azure_claude_api_key: str = ""
|
||||||
|
azure_claude_endpoint: str = ""
|
||||||
|
azure_claude_base_url: str = ""
|
||||||
|
azure_claude_api_version: str = "2024-10-21"
|
||||||
|
azure_claude_deployment: str = ""
|
||||||
|
azure_claude_model: str = "claude-sonnet-4.6"
|
||||||
|
|
||||||
# Supabase
|
# Supabase
|
||||||
supabase_url: str
|
supabase_url: str
|
||||||
@@ -100,7 +106,8 @@ class Settings(BaseSettings):
|
|||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
env_file=".env",
|
env_file=".env",
|
||||||
env_file_encoding="utf-8",
|
env_file_encoding="utf-8",
|
||||||
case_sensitive=False
|
case_sensitive=False,
|
||||||
|
extra="ignore"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -111,13 +118,24 @@ settings = Settings()
|
|||||||
API_PRICING = {
|
API_PRICING = {
|
||||||
"gpt-4o": {"input": 2.50, "output": 10.00},
|
"gpt-4o": {"input": 2.50, "output": 10.00},
|
||||||
"gpt-4o-mini": {"input": 0.15, "output": 0.60},
|
"gpt-4o-mini": {"input": 0.15, "output": 0.60},
|
||||||
|
"claude-sonnet-4.6": {"input": 3.00, "output": 15.00},
|
||||||
"sonar": {"input": 1.00, "output": 1.00},
|
"sonar": {"input": 1.00, "output": 1.00},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def estimate_cost(model: str, prompt_tokens: int, completion_tokens: int) -> float:
|
def estimate_cost(model: str, prompt_tokens: int, completion_tokens: int) -> float:
|
||||||
"""Estimate cost in USD for an API call."""
|
"""Estimate cost in USD for an API call."""
|
||||||
pricing = API_PRICING.get(model, {"input": 1.00, "output": 1.00})
|
normalized_model = (model or "").lower()
|
||||||
|
|
||||||
|
if normalized_model not in API_PRICING:
|
||||||
|
if "claude" in normalized_model and "sonnet" in normalized_model:
|
||||||
|
normalized_model = "claude-sonnet-4.6"
|
||||||
|
elif "gpt-4o-mini" in normalized_model:
|
||||||
|
normalized_model = "gpt-4o-mini"
|
||||||
|
elif "gpt-4o" in normalized_model:
|
||||||
|
normalized_model = "gpt-4o"
|
||||||
|
|
||||||
|
pricing = API_PRICING.get(normalized_model, {"input": 1.00, "output": 1.00})
|
||||||
input_cost = (prompt_tokens / 1_000_000) * pricing["input"]
|
input_cost = (prompt_tokens / 1_000_000) * pricing["input"]
|
||||||
output_cost = (completion_tokens / 1_000_000) * pricing["output"]
|
output_cost = (completion_tokens / 1_000_000) * pricing["output"]
|
||||||
return input_cost + output_cost
|
return input_cost + output_cost
|
||||||
|
|||||||
@@ -616,7 +616,7 @@ class WorkflowOrchestrator:
|
|||||||
self,
|
self,
|
||||||
user_id: UUID,
|
user_id: UUID,
|
||||||
topic: Dict[str, Any],
|
topic: Dict[str, Any],
|
||||||
max_iterations: int = 3,
|
max_iterations: int = 2,
|
||||||
progress_callback: Optional[Callable[[str, int, int, Optional[int], Optional[List], Optional[List]], None]] = None,
|
progress_callback: Optional[Callable[[str, int, int, Optional[int], Optional[List], Optional[List]], None]] = None,
|
||||||
post_type_id: Optional[UUID] = None,
|
post_type_id: Optional[UUID] = None,
|
||||||
user_thoughts: str = "",
|
user_thoughts: str = "",
|
||||||
@@ -741,7 +741,7 @@ class WorkflowOrchestrator:
|
|||||||
company_strategy=company_strategy, # Pass company strategy
|
company_strategy=company_strategy, # Pass company strategy
|
||||||
strategy_weight=strategy_weight # NEW: Pass strategy weight
|
strategy_weight=strategy_weight # NEW: Pass strategy weight
|
||||||
)
|
)
|
||||||
current_post = sanitize_post_content(current_post)
|
current_post = sanitize_post_content(current_post, profile_analysis.full_analysis)
|
||||||
else:
|
else:
|
||||||
# Revision based on feedback - pass full critic result for structured changes
|
# Revision based on feedback - pass full critic result for structured changes
|
||||||
report_progress("Writer überarbeitet Post...", iteration, None, writer_versions, critic_feedback_list)
|
report_progress("Writer überarbeitet Post...", iteration, None, writer_versions, critic_feedback_list)
|
||||||
@@ -761,9 +761,9 @@ class WorkflowOrchestrator:
|
|||||||
company_strategy=company_strategy, # Pass company strategy
|
company_strategy=company_strategy, # Pass company strategy
|
||||||
strategy_weight=strategy_weight # NEW: Pass strategy weight
|
strategy_weight=strategy_weight # NEW: Pass strategy weight
|
||||||
)
|
)
|
||||||
current_post = sanitize_post_content(current_post)
|
current_post = sanitize_post_content(current_post, profile_analysis.full_analysis)
|
||||||
|
|
||||||
writer_versions.append(sanitize_post_content(current_post))
|
writer_versions.append(sanitize_post_content(current_post, profile_analysis.full_analysis))
|
||||||
logger.info(f"Writer produced version {iteration}")
|
logger.info(f"Writer produced version {iteration}")
|
||||||
|
|
||||||
# Report progress with new version
|
# Report progress with new version
|
||||||
@@ -868,7 +868,7 @@ class WorkflowOrchestrator:
|
|||||||
profile_analysis=profile_analysis.full_analysis,
|
profile_analysis=profile_analysis.full_analysis,
|
||||||
example_posts=example_post_texts
|
example_posts=example_post_texts
|
||||||
)
|
)
|
||||||
current_post = sanitize_post_content(polished_post)
|
current_post = sanitize_post_content(polished_post, profile_analysis.full_analysis)
|
||||||
logger.info("✅ Post polished (Formatierung erhalten)")
|
logger.info("✅ Post polished (Formatierung erhalten)")
|
||||||
else:
|
else:
|
||||||
logger.info("✅ No quality issues, skipping polish")
|
logger.info("✅ No quality issues, skipping polish")
|
||||||
@@ -894,7 +894,7 @@ class WorkflowOrchestrator:
|
|||||||
generated_post = GeneratedPost(
|
generated_post = GeneratedPost(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
topic_title=topic.get("title", "Unknown"),
|
topic_title=topic.get("title", "Unknown"),
|
||||||
post_content=sanitize_post_content(current_post),
|
post_content=sanitize_post_content(current_post, profile_analysis.full_analysis),
|
||||||
iterations=iteration,
|
iterations=iteration,
|
||||||
writer_versions=writer_versions,
|
writer_versions=writer_versions,
|
||||||
critic_feedback=critic_feedback_list,
|
critic_feedback=critic_feedback_list,
|
||||||
@@ -1127,10 +1127,10 @@ Gib JSON im Format:
|
|||||||
"company_alignment": ["Erforderliche Strategy-Elemente aus strategy_required falls vorhanden"]
|
"company_alignment": ["Erforderliche Strategy-Elemente aus strategy_required falls vorhanden"]
|
||||||
}}
|
}}
|
||||||
"""
|
"""
|
||||||
raw = await self.writer.call_openai(
|
raw = await self.writer.call_azure_claude(
|
||||||
system_prompt=system_prompt,
|
system_prompt=system_prompt,
|
||||||
user_prompt=user_prompt,
|
user_prompt=user_prompt,
|
||||||
model="gpt-4o",
|
model=settings.azure_claude_model,
|
||||||
temperature=0.2,
|
temperature=0.2,
|
||||||
response_format={"type": "json_object"}
|
response_format={"type": "json_object"}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -615,7 +615,7 @@ class CreatePostScreen(Screen):
|
|||||||
return await orchestrator.create_post(
|
return await orchestrator.create_post(
|
||||||
customer_id=customer_id,
|
customer_id=customer_id,
|
||||||
topic=topic,
|
topic=topic,
|
||||||
max_iterations=3,
|
max_iterations=2,
|
||||||
progress_callback=self._update_post_progress
|
progress_callback=self._update_post_progress
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,75 @@
|
|||||||
"""Utilities to sanitize generated post content."""
|
"""Utilities to sanitize generated post content."""
|
||||||
import re
|
import re
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
|
||||||
def sanitize_post_content(text: str) -> str:
|
UNICODE_BOLD_MAP = {
|
||||||
|
**{chr(ord('A') + i): chr(0x1D5D4 + i) for i in range(26)},
|
||||||
|
**{chr(ord('a') + i): chr(0x1D5EE + i) for i in range(26)},
|
||||||
|
**{chr(ord('0') + i): chr(0x1D7EC + i) for i in range(10)},
|
||||||
|
}
|
||||||
|
UNICODE_BOLD_REVERSE_MAP = {v: k for k, v in UNICODE_BOLD_MAP.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_dict(value: Any) -> Dict[str, Any]:
|
||||||
|
return value if isinstance(value, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_format_profile(profile_analysis: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||||
|
if not isinstance(profile_analysis, dict):
|
||||||
|
return {
|
||||||
|
"hook_bold": "auto",
|
||||||
|
"emoji_frequency": "mittel",
|
||||||
|
"emoji_placement": "ende",
|
||||||
|
}
|
||||||
|
|
||||||
|
visual = _ensure_dict(profile_analysis.get("visual_patterns", {}))
|
||||||
|
emoji_usage = _ensure_dict(visual.get("emoji_usage", {}))
|
||||||
|
unicode_formatting = str(visual.get("unicode_formatting", "") or "").lower()
|
||||||
|
|
||||||
|
hook_bold = "auto"
|
||||||
|
if any(token in unicode_formatting for token in ["kein fett", "ohne fett", "nie fett", "keine fettung"]):
|
||||||
|
hook_bold = "forbidden"
|
||||||
|
elif any(token in unicode_formatting for token in ["fett für hooks", "hook fett", "fette unicode", "unicode-fett", "fettung"]):
|
||||||
|
hook_bold = "required"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"hook_bold": hook_bold,
|
||||||
|
"emoji_frequency": str(emoji_usage.get("frequency", "mittel") or "mittel").lower(),
|
||||||
|
"emoji_placement": str(emoji_usage.get("placement", "ende") or "ende").lower(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _to_unicode_bold(text: str) -> str:
|
||||||
|
return "".join(UNICODE_BOLD_MAP.get(ch, ch) for ch in text)
|
||||||
|
|
||||||
|
|
||||||
|
def _from_unicode_bold(text: str) -> str:
|
||||||
|
return "".join(UNICODE_BOLD_REVERSE_MAP.get(ch, ch) for ch in text)
|
||||||
|
|
||||||
|
|
||||||
|
def _enforce_hook_formatting(text: str, format_profile: Dict[str, Any]) -> str:
|
||||||
|
lines = text.splitlines()
|
||||||
|
if not lines:
|
||||||
|
return text
|
||||||
|
|
||||||
|
first_nonempty_index = next((i for i, line in enumerate(lines) if line.strip()), None)
|
||||||
|
if first_nonempty_index is None:
|
||||||
|
return text
|
||||||
|
|
||||||
|
hook = lines[first_nonempty_index].strip()
|
||||||
|
hook_mode = format_profile.get("hook_bold", "auto")
|
||||||
|
|
||||||
|
if hook_mode == "required":
|
||||||
|
hook = _to_unicode_bold(_from_unicode_bold(hook))
|
||||||
|
elif hook_mode == "forbidden":
|
||||||
|
hook = _from_unicode_bold(hook)
|
||||||
|
|
||||||
|
lines[first_nonempty_index] = hook
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_post_content(text: str, profile_analysis: Optional[Dict[str, Any]] = None) -> str:
|
||||||
"""Remove markdown bold and leading 'Post' labels from generated content."""
|
"""Remove markdown bold and leading 'Post' labels from generated content."""
|
||||||
if not text:
|
if not text:
|
||||||
return text
|
return text
|
||||||
@@ -19,4 +86,11 @@ def sanitize_post_content(text: str) -> str:
|
|||||||
# Remove any leftover bold markers
|
# Remove any leftover bold markers
|
||||||
cleaned = cleaned.replace('**', '').replace('__', '')
|
cleaned = cleaned.replace('**', '').replace('__', '')
|
||||||
|
|
||||||
|
# Remove unsupported divider lines
|
||||||
|
cleaned = re.sub(r'(?m)^\s*---+\s*$', '', cleaned)
|
||||||
|
cleaned = re.sub(r'\n{3,}', '\n\n', cleaned).strip()
|
||||||
|
|
||||||
|
# Apply deterministic format rules from the profile after generic cleanup
|
||||||
|
cleaned = _enforce_hook_formatting(cleaned, _extract_format_profile(profile_analysis))
|
||||||
|
|
||||||
return cleaned.strip()
|
return cleaned.strip()
|
||||||
|
|||||||
@@ -298,7 +298,12 @@ function renderCharts(data) {
|
|||||||
if (data.daily.length > 0) {
|
if (data.daily.length > 0) {
|
||||||
const tokenSeries = [{ name: 'Gesamt', data: data.daily.map(d => ({ x: d.date, y: d.tokens })) }];
|
const tokenSeries = [{ name: 'Gesamt', data: data.daily.map(d => ({ x: d.date, y: d.tokens })) }];
|
||||||
const costSeries = [{ name: 'Gesamt', data: data.daily.map(d => ({ x: d.date, y: parseFloat(d.cost.toFixed(4)) })) }];
|
const costSeries = [{ name: 'Gesamt', data: data.daily.map(d => ({ x: d.date, y: parseFloat(d.cost.toFixed(4)) })) }];
|
||||||
const modelColors = { 'gpt-4o': '#60A5FA', 'gpt-4o-mini': '#A78BFA', 'sonar': '#F87171' };
|
const modelColors = {
|
||||||
|
'gpt-4o': '#60A5FA',
|
||||||
|
'gpt-4o-mini': '#A78BFA',
|
||||||
|
'claude-sonnet-4.6': '#F59E0B',
|
||||||
|
'sonar': '#F87171'
|
||||||
|
};
|
||||||
|
|
||||||
if (data.daily_by_model) {
|
if (data.daily_by_model) {
|
||||||
for (const [model, days] of Object.entries(data.daily_by_model)) {
|
for (const [model, days] of Object.entries(data.daily_by_model)) {
|
||||||
|
|||||||
@@ -2364,7 +2364,7 @@ async def create_post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
result = await orchestrator.create_post(
|
result = await orchestrator.create_post(
|
||||||
user_id=UUID(user_id), topic=topic, max_iterations=3,
|
user_id=UUID(user_id), topic=topic, max_iterations=2,
|
||||||
progress_callback=progress_callback,
|
progress_callback=progress_callback,
|
||||||
post_type_id=UUID(post_type_id) if post_type_id else None,
|
post_type_id=UUID(post_type_id) if post_type_id else None,
|
||||||
user_thoughts=user_thoughts,
|
user_thoughts=user_thoughts,
|
||||||
@@ -4412,7 +4412,7 @@ async def chat_generate_post(request: Request):
|
|||||||
company_strategy=company_strategy,
|
company_strategy=company_strategy,
|
||||||
strategy_weight=strategy_weight
|
strategy_weight=strategy_weight
|
||||||
)
|
)
|
||||||
post_content = sanitize_post_content(post_content)
|
post_content = sanitize_post_content(post_content, profile_analysis.full_analysis)
|
||||||
|
|
||||||
# Run critic + one revision pass for chat flow quality parity
|
# Run critic + one revision pass for chat flow quality parity
|
||||||
critic_result = await orchestrator.critic.process(
|
critic_result = await orchestrator.critic.process(
|
||||||
@@ -4437,7 +4437,7 @@ async def chat_generate_post(request: Request):
|
|||||||
company_strategy=company_strategy,
|
company_strategy=company_strategy,
|
||||||
strategy_weight=strategy_weight
|
strategy_weight=strategy_weight
|
||||||
)
|
)
|
||||||
post_content = sanitize_post_content(post_content)
|
post_content = sanitize_post_content(post_content, profile_analysis.full_analysis)
|
||||||
critic_result = await orchestrator.critic.process(
|
critic_result = await orchestrator.critic.process(
|
||||||
post=post_content,
|
post=post_content,
|
||||||
profile_analysis=profile_analysis.full_analysis,
|
profile_analysis=profile_analysis.full_analysis,
|
||||||
@@ -4466,7 +4466,7 @@ async def chat_generate_post(request: Request):
|
|||||||
profile_analysis=profile_analysis.full_analysis,
|
profile_analysis=profile_analysis.full_analysis,
|
||||||
example_posts=example_post_texts
|
example_posts=example_post_texts
|
||||||
)
|
)
|
||||||
post_content = sanitize_post_content(post_content)
|
post_content = sanitize_post_content(post_content, profile_analysis.full_analysis)
|
||||||
|
|
||||||
# Generate conversation ID
|
# Generate conversation ID
|
||||||
import uuid
|
import uuid
|
||||||
@@ -4597,7 +4597,7 @@ async def chat_refine_post(request: Request):
|
|||||||
company_strategy=company_strategy,
|
company_strategy=company_strategy,
|
||||||
strategy_weight=strategy_weight
|
strategy_weight=strategy_weight
|
||||||
)
|
)
|
||||||
refined_post = sanitize_post_content(refined_post)
|
refined_post = sanitize_post_content(refined_post, full_analysis)
|
||||||
|
|
||||||
# Critic + quality checks for chat refine parity
|
# Critic + quality checks for chat refine parity
|
||||||
critic_result = await orchestrator.critic.process(
|
critic_result = await orchestrator.critic.process(
|
||||||
@@ -4622,7 +4622,7 @@ async def chat_refine_post(request: Request):
|
|||||||
company_strategy=company_strategy,
|
company_strategy=company_strategy,
|
||||||
strategy_weight=strategy_weight
|
strategy_weight=strategy_weight
|
||||||
)
|
)
|
||||||
refined_post = sanitize_post_content(refined_post)
|
refined_post = sanitize_post_content(refined_post, full_analysis)
|
||||||
|
|
||||||
critic_result = await orchestrator.critic.process(
|
critic_result = await orchestrator.critic.process(
|
||||||
post=refined_post,
|
post=refined_post,
|
||||||
@@ -4651,7 +4651,7 @@ async def chat_refine_post(request: Request):
|
|||||||
profile_analysis=full_analysis,
|
profile_analysis=full_analysis,
|
||||||
example_posts=example_post_texts
|
example_posts=example_post_texts
|
||||||
)
|
)
|
||||||
refined_post = sanitize_post_content(refined_post)
|
refined_post = sanitize_post_content(refined_post, full_analysis)
|
||||||
|
|
||||||
return JSONResponse({
|
return JSONResponse({
|
||||||
"success": True,
|
"success": True,
|
||||||
@@ -4974,7 +4974,7 @@ async def company_chat_generate_post(request: Request):
|
|||||||
company_strategy=company_strategy,
|
company_strategy=company_strategy,
|
||||||
strategy_weight=strategy_weight
|
strategy_weight=strategy_weight
|
||||||
)
|
)
|
||||||
post_content = sanitize_post_content(post_content)
|
post_content = sanitize_post_content(post_content, profile_analysis.full_analysis)
|
||||||
|
|
||||||
# Run critic + one revision pass for chat flow quality parity
|
# Run critic + one revision pass for chat flow quality parity
|
||||||
critic_result = await orchestrator.critic.process(
|
critic_result = await orchestrator.critic.process(
|
||||||
@@ -4998,7 +4998,7 @@ async def company_chat_generate_post(request: Request):
|
|||||||
company_strategy=company_strategy,
|
company_strategy=company_strategy,
|
||||||
strategy_weight=strategy_weight
|
strategy_weight=strategy_weight
|
||||||
)
|
)
|
||||||
post_content = sanitize_post_content(post_content)
|
post_content = sanitize_post_content(post_content, profile_analysis.full_analysis)
|
||||||
|
|
||||||
if settings.quality_refiner_enabled:
|
if settings.quality_refiner_enabled:
|
||||||
quality_checks = await orchestrator._run_quality_checks(post_content, example_post_texts)
|
quality_checks = await orchestrator._run_quality_checks(post_content, example_post_texts)
|
||||||
@@ -5017,7 +5017,7 @@ async def company_chat_generate_post(request: Request):
|
|||||||
profile_analysis=profile_analysis.full_analysis,
|
profile_analysis=profile_analysis.full_analysis,
|
||||||
example_posts=example_post_texts
|
example_posts=example_post_texts
|
||||||
)
|
)
|
||||||
post_content = sanitize_post_content(post_content)
|
post_content = sanitize_post_content(post_content, profile_analysis.full_analysis)
|
||||||
|
|
||||||
return JSONResponse({
|
return JSONResponse({
|
||||||
"success": True,
|
"success": True,
|
||||||
|
|||||||
Reference in New Issue
Block a user