changed to claude

This commit is contained in:
2026-04-02 09:46:35 +02:00
parent b3bb67f3ad
commit 252edcd001
12 changed files with 567 additions and 75 deletions

0
.codex Normal file
View File

View File

@@ -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

View File

@@ -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,

View File

@@ -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",

View File

@@ -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
) )

View File

@@ -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
) )

View File

@@ -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

View File

@@ -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"}
) )

View File

@@ -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
) )

View File

@@ -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()

View File

@@ -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)) {

View File

@@ -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,