changed to claude
This commit is contained in:
@@ -5,6 +5,7 @@ pydantic-settings==2.3.0
|
||||
|
||||
# AI & APIs
|
||||
openai==1.54.0
|
||||
anthropic>=0.49.0
|
||||
apify-client==1.7.0
|
||||
|
||||
# Database
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import asyncio
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
from anthropic import AnthropicFoundry
|
||||
from openai import OpenAI
|
||||
import httpx
|
||||
from loguru import logger
|
||||
@@ -12,6 +14,31 @@ from src.database.client import db
|
||||
|
||||
class BaseAgent(ABC):
|
||||
"""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):
|
||||
"""
|
||||
@@ -22,6 +49,17 @@ class BaseAgent(ABC):
|
||||
"""
|
||||
self.name = name
|
||||
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._user_id: Optional[str] = None
|
||||
self._company_id: Optional[str] = None
|
||||
@@ -141,6 +179,83 @@ class BaseAgent(ABC):
|
||||
|
||||
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(
|
||||
self,
|
||||
system_prompt: str,
|
||||
|
||||
@@ -20,7 +20,7 @@ class CriticAgent(BaseAgent):
|
||||
topic: Dict[str, Any],
|
||||
example_posts: Optional[List[str]] = None,
|
||||
iteration: int = 1,
|
||||
max_iterations: int = 3
|
||||
max_iterations: int = 2
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Review a LinkedIn post and provide feedback.
|
||||
@@ -57,7 +57,7 @@ class CriticAgent(BaseAgent):
|
||||
|
||||
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."""
|
||||
def _ensure_dict(value: Any) -> Dict[str, Any]:
|
||||
return value if isinstance(value, dict) else {}
|
||||
@@ -78,6 +78,7 @@ class CriticAgent(BaseAgent):
|
||||
structure_templates = profile_analysis.get("structure_templates", {})
|
||||
structure_templates_dict = _ensure_dict(structure_templates)
|
||||
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
|
||||
examples_section = ""
|
||||
@@ -96,6 +97,8 @@ class CriticAgent(BaseAgent):
|
||||
hook_phrases = phrase_library.get('hook_phrases', [])
|
||||
emotional_expressions = phrase_library.get('emotional_expressions', [])
|
||||
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
|
||||
if isinstance(structure_templates, list):
|
||||
@@ -129,7 +132,7 @@ ITERATION {iteration}/{max_iterations} - Fortschritt anerkennen:
|
||||
- Fokussiere auf verbleibende Verbesserungen
|
||||
- Erwarteter Score-Bereich: 75-90 (wenn erste Kritik gut umgesetzt)"""
|
||||
|
||||
return f"""ROLLE: Du bist ein präziser Chefredakteur für Personal Branding. Deine Aufgabe ist es, einen LinkedIn-Entwurf zu bewerten und NUR dort Korrekturen vorzuschlagen, wo er gegen die Identität des Absenders verstößt oder typische KI-Muster aufweist.
|
||||
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}
|
||||
{iteration_guidance}
|
||||
|
||||
@@ -142,12 +145,41 @@ Energie-Level: {linguistic.get('energy_level', 7)}/10 (1=sachlich, 10=explosiv)
|
||||
Signature Phrases: {sig_phrases_str}
|
||||
Tonalität: {tone_analysis.get('primary_tone', 'Professionell')}
|
||||
Erwartete Struktur: {primary_structure}
|
||||
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):
|
||||
- Hook-Stil Beispiele: {', '.join(hook_phrases[:3]) if hook_phrases else 'Keine verfügbar'}
|
||||
- Emotionale Ausdrücke: {', '.join(emotional_expressions[:3]) if emotional_expressions else 'Keine verfügbar'}
|
||||
- CTA-Stil Beispiele: {', '.join(cta_phrases[:2]) if cta_phrases else 'Keine verfügbar'}
|
||||
|
||||
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!):
|
||||
|
||||
@@ -186,6 +218,12 @@ CHIRURGISCHE KORREKTUR-REGELN (Prüfe diese Punkte!):
|
||||
- Der CTA sollte im gleichen Stil sein wie die CTA-Beispiele
|
||||
- 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):
|
||||
|
||||
@@ -215,6 +253,10 @@ BEWERTUNGSKRITERIEN (100 Punkte total):
|
||||
- Korrekte Formatierung
|
||||
- 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!):
|
||||
|
||||
@@ -252,7 +294,7 @@ WICHTIG FÜR DEIN FEEDBACK:
|
||||
|
||||
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."""
|
||||
iteration_note = ""
|
||||
if iteration > 1:
|
||||
@@ -287,6 +329,10 @@ Antworte im JSON-Format:
|
||||
"strengths": ["Stärke 1", "Stärke 2"],
|
||||
"improvements": ["Verbesserung 1", "Verbesserung 2"],
|
||||
"feedback": "Kurze Zusammenfassung",
|
||||
"format_compliance": {{
|
||||
"score": 0-100,
|
||||
"issues": ["Format-Thema 1", "Format-Thema 2"]
|
||||
}},
|
||||
"specific_changes": [
|
||||
{{
|
||||
"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 src.agents.base import BaseAgent
|
||||
from src.config import settings
|
||||
|
||||
|
||||
class QualityRefinerAgent(BaseAgent):
|
||||
@@ -237,10 +238,10 @@ class QualityRefinerAgent(BaseAgent):
|
||||
system_prompt = self._get_final_polish_system_prompt(profile_analysis, example_posts)
|
||||
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,
|
||||
user_prompt=user_prompt,
|
||||
model="gpt-4o",
|
||||
model=settings.azure_claude_model,
|
||||
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)
|
||||
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,
|
||||
user_prompt=user_prompt,
|
||||
model="gpt-4o",
|
||||
model=settings.azure_claude_model,
|
||||
temperature=0.4 # Lower temp for more controlled revision
|
||||
)
|
||||
|
||||
|
||||
@@ -125,14 +125,14 @@ class WriterAgent(BaseAgent):
|
||||
profile_analysis: Profile analysis results
|
||||
|
||||
Returns:
|
||||
Selected example posts (3-4 posts)
|
||||
Selected example posts (always max 2)
|
||||
"""
|
||||
if not example_posts or len(example_posts) == 0:
|
||||
return []
|
||||
|
||||
if not settings.writer_semantic_matching_enabled:
|
||||
# Fallback to random selection
|
||||
num_examples = min(3, len(example_posts))
|
||||
num_examples = min(2, len(example_posts))
|
||||
selected = random.sample(example_posts, num_examples)
|
||||
logger.info(f"Using {len(selected)} random example posts")
|
||||
return selected
|
||||
@@ -168,7 +168,7 @@ class WriterAgent(BaseAgent):
|
||||
# Sort by score (highest first)
|
||||
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 = []
|
||||
|
||||
# Top 2 most relevant
|
||||
@@ -177,15 +177,8 @@ class WriterAgent(BaseAgent):
|
||||
selected.append(item["post"])
|
||||
logger.debug(f"Selected post (score {item['score']:.1f}, keywords: {item['matched'][:3]})")
|
||||
|
||||
# Add 1 random post for variety (if not already selected)
|
||||
remaining_posts = [p["post"] for p in scored_posts[2:] if p["post"] not in selected]
|
||||
if remaining_posts and len(selected) < 3:
|
||||
random_pick = random.choice(remaining_posts)
|
||||
selected.append(random_pick)
|
||||
logger.debug("Added 1 random post for variety")
|
||||
|
||||
# If we still don't have enough, fill with top scored
|
||||
while len(selected) < 3 and len(selected) < len(example_posts):
|
||||
while len(selected) < 2 and len(selected) < len(example_posts):
|
||||
found = False
|
||||
for item in scored_posts:
|
||||
if item["post"] not in selected:
|
||||
@@ -231,6 +224,33 @@ class WriterAgent(BaseAgent):
|
||||
|
||||
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(
|
||||
self,
|
||||
topic: Dict[str, Any],
|
||||
@@ -283,10 +303,10 @@ class WriterAgent(BaseAgent):
|
||||
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)
|
||||
try:
|
||||
draft = await self.call_openai(
|
||||
draft = await self.call_azure_claude(
|
||||
system_prompt=system_prompt,
|
||||
user_prompt=user_prompt,
|
||||
model="gpt-4o",
|
||||
model=settings.azure_claude_model,
|
||||
temperature=config["temperature"]
|
||||
)
|
||||
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"
|
||||
}}"""
|
||||
|
||||
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.",
|
||||
user_prompt=selector_prompt,
|
||||
model="gpt-4o-mini", # Use cheaper model for selection
|
||||
model=settings.azure_claude_model,
|
||||
temperature=0.2,
|
||||
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
|
||||
if len(selected_examples) == 0:
|
||||
pass # No examples available
|
||||
elif len(selected_examples) > 3:
|
||||
selected_examples = random.sample(selected_examples, 3)
|
||||
elif len(selected_examples) > 2:
|
||||
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(
|
||||
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)
|
||||
|
||||
# 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,
|
||||
user_prompt=user_prompt,
|
||||
model="gpt-4o",
|
||||
model=settings.azure_claude_model,
|
||||
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", {}))
|
||||
visual = _ensure_dict(profile_analysis.get("visual_patterns", {}))
|
||||
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):
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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_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'))}
|
||||
- Energie: {linguistic.get('energy_level', 7)}/10
|
||||
- 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'}
|
||||
- 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'}
|
||||
- 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'}
|
||||
- Ü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')}
|
||||
- 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(
|
||||
@@ -648,13 +716,14 @@ Analysiere jeden Entwurf kurz und wähle den besten. Antworte im JSON-Format:
|
||||
) -> str:
|
||||
"""Short, high-signal system prompt to enforce style without overload."""
|
||||
style_card = self._build_style_card(profile_analysis)
|
||||
format_card = self._build_format_card(profile_analysis)
|
||||
|
||||
examples_section = ""
|
||||
if example_posts:
|
||||
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):
|
||||
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"
|
||||
|
||||
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.
|
||||
{style_card}
|
||||
{format_card}
|
||||
{strategy_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:
|
||||
- Inhalt strikt an den Content-Plan halten, keine neuen Fakten erfinden
|
||||
- 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 Markdown-Fettung (kein **), keine Trennlinien (---)
|
||||
- Ausgabe: Nur der fertige Post"""
|
||||
@@ -1283,34 +1376,54 @@ Gib NUR den fertigen Post zurück."""
|
||||
if hook_phrases:
|
||||
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:
|
||||
- Tonalität: {tone}
|
||||
- Energie-Level: {energy}/10 (höher = emotionaler, niedriger = sachlicher)
|
||||
- Ansprache: {address}
|
||||
{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
|
||||
2. **Storytelling** - Beginn einer persönlichen Geschichte oder Anekdote
|
||||
3. **Fakten-basiert** - Eine überraschende Statistik oder Fakt
|
||||
4. **Neugier-weckend** - Eine Frage oder ein Cliffhanger
|
||||
5. **Pointiert** - Eine präzise, starke Beobachtung
|
||||
6. **Reflektiert** - Eine nachdenkliche, glaubwürdige Einordnung
|
||||
|
||||
REGELN:
|
||||
- Jeder Hook sollte 1-2 Sätze lang sein
|
||||
- Hooks müssen zum Energie-Level und Ton der Person passen
|
||||
- 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 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:
|
||||
{{"hooks": [
|
||||
{{"hook": "Der Hook-Text hier", "style": "Provokant"}},
|
||||
{{"hook": "Der Hook-Text hier", "style": "Storytelling"}},
|
||||
{{"hook": "Der Hook-Text hier", "style": "Fakten-basiert"}},
|
||||
{{"hook": "Der Hook-Text hier", "style": "Neugier-weckend"}}
|
||||
{{"hook": "Der Hook-Text hier", "style": "Provokant", "anchor": "welcher konkrete Fakt oder Gedanke dahinter steckt"}},
|
||||
{{"hook": "Der Hook-Text hier", "style": "Storytelling", "anchor": "welcher konkrete Fakt oder Gedanke dahinter steckt"}},
|
||||
{{"hook": "Der Hook-Text hier", "style": "Fakten-basiert", "anchor": "welcher konkrete Fakt oder Gedanke dahinter steckt"}},
|
||||
{{"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
|
||||
@@ -1334,7 +1447,7 @@ Antworte im JSON-Format:
|
||||
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', [])])
|
||||
|
||||
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')}
|
||||
|
||||
@@ -1344,31 +1457,150 @@ KERN-FAKT/INHALT:
|
||||
{content_block}
|
||||
{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,
|
||||
user_prompt=user_prompt,
|
||||
model="gpt-4o-mini",
|
||||
temperature=0.8,
|
||||
model=settings.azure_claude_model,
|
||||
temperature=0.6,
|
||||
response_format={"type": "json_object"}
|
||||
)
|
||||
|
||||
try:
|
||||
result = json.loads(response)
|
||||
result = self._parse_json_response(response)
|
||||
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")
|
||||
return hooks
|
||||
except (json.JSONDecodeError, KeyError) as e:
|
||||
except (json.JSONDecodeError, KeyError, ValueError) as 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 [
|
||||
{"hook": f"Was wäre, wenn {topic.get('title', 'dieses Thema')} alles verändert?", "style": "Neugier-weckend"},
|
||||
{"hook": f"Letzte Woche habe ich etwas über {topic.get('title', 'dieses Thema')} gelernt, das mich nicht mehr loslässt.", "style": "Storytelling"},
|
||||
{"hook": f"Die meisten unterschätzen {topic.get('title', 'dieses Thema')}. Ein Fehler.", "style": "Provokant"},
|
||||
{"hook": f"Eine Zahl hat mich diese Woche überrascht: {topic.get('fact', 'Ein überraschender Fakt')}.", "style": "Fakten-basiert"}
|
||||
{"hook": f"An {title} ist für mich nicht die Schlagzeile entscheidend. Sondern die Verschiebung dahinter.", "style": "Pointiert"},
|
||||
{"hook": f"Mich interessiert an {title} weniger der Hype als die Frage, was sich dadurch real verändert.", "style": "Reflektiert"},
|
||||
{"hook": f"Wenn {fact} stimmt, ist das deutlich mehr als nur eine Randnotiz.", "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(
|
||||
self,
|
||||
post_content: str,
|
||||
@@ -1444,10 +1676,10 @@ Antworte im JSON-Format:
|
||||
|
||||
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,
|
||||
user_prompt=user_prompt,
|
||||
model="gpt-4o-mini",
|
||||
model=settings.azure_claude_model,
|
||||
temperature=0.7,
|
||||
response_format={"type": "json_object"}
|
||||
)
|
||||
@@ -1511,10 +1743,10 @@ ANZUWENDENDE VERBESSERUNG:
|
||||
|
||||
Schreibe jetzt den überarbeiteten Post:"""
|
||||
|
||||
response = await self.call_openai(
|
||||
response = await self.call_azure_claude(
|
||||
system_prompt=system_prompt,
|
||||
user_prompt=user_prompt,
|
||||
model="gpt-4o-mini",
|
||||
model=settings.azure_claude_model,
|
||||
temperature=0.5
|
||||
)
|
||||
|
||||
|
||||
@@ -11,6 +11,12 @@ class Settings(BaseSettings):
|
||||
openai_api_key: str
|
||||
perplexity_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_url: str
|
||||
@@ -100,7 +106,8 @@ class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False
|
||||
case_sensitive=False,
|
||||
extra="ignore"
|
||||
)
|
||||
|
||||
|
||||
@@ -111,13 +118,24 @@ settings = Settings()
|
||||
API_PRICING = {
|
||||
"gpt-4o": {"input": 2.50, "output": 10.00},
|
||||
"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},
|
||||
}
|
||||
|
||||
|
||||
def estimate_cost(model: str, prompt_tokens: int, completion_tokens: int) -> float:
|
||||
"""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"]
|
||||
output_cost = (completion_tokens / 1_000_000) * pricing["output"]
|
||||
return input_cost + output_cost
|
||||
|
||||
@@ -616,7 +616,7 @@ class WorkflowOrchestrator:
|
||||
self,
|
||||
user_id: UUID,
|
||||
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,
|
||||
post_type_id: Optional[UUID] = None,
|
||||
user_thoughts: str = "",
|
||||
@@ -741,7 +741,7 @@ class WorkflowOrchestrator:
|
||||
company_strategy=company_strategy, # Pass company strategy
|
||||
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:
|
||||
# Revision based on feedback - pass full critic result for structured changes
|
||||
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
|
||||
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}")
|
||||
|
||||
# Report progress with new version
|
||||
@@ -868,7 +868,7 @@ class WorkflowOrchestrator:
|
||||
profile_analysis=profile_analysis.full_analysis,
|
||||
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)")
|
||||
else:
|
||||
logger.info("✅ No quality issues, skipping polish")
|
||||
@@ -894,7 +894,7 @@ class WorkflowOrchestrator:
|
||||
generated_post = GeneratedPost(
|
||||
user_id=user_id,
|
||||
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,
|
||||
writer_versions=writer_versions,
|
||||
critic_feedback=critic_feedback_list,
|
||||
@@ -1127,10 +1127,10 @@ Gib JSON im Format:
|
||||
"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,
|
||||
user_prompt=user_prompt,
|
||||
model="gpt-4o",
|
||||
model=settings.azure_claude_model,
|
||||
temperature=0.2,
|
||||
response_format={"type": "json_object"}
|
||||
)
|
||||
|
||||
@@ -615,7 +615,7 @@ class CreatePostScreen(Screen):
|
||||
return await orchestrator.create_post(
|
||||
customer_id=customer_id,
|
||||
topic=topic,
|
||||
max_iterations=3,
|
||||
max_iterations=2,
|
||||
progress_callback=self._update_post_progress
|
||||
)
|
||||
|
||||
|
||||
@@ -1,8 +1,75 @@
|
||||
"""Utilities to sanitize generated post content."""
|
||||
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."""
|
||||
if not text:
|
||||
return text
|
||||
@@ -19,4 +86,11 @@ def sanitize_post_content(text: str) -> str:
|
||||
# Remove any leftover bold markers
|
||||
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()
|
||||
|
||||
@@ -298,7 +298,12 @@ function renderCharts(data) {
|
||||
if (data.daily.length > 0) {
|
||||
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 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) {
|
||||
for (const [model, days] of Object.entries(data.daily_by_model)) {
|
||||
|
||||
@@ -2364,7 +2364,7 @@ async def 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,
|
||||
post_type_id=UUID(post_type_id) if post_type_id else None,
|
||||
user_thoughts=user_thoughts,
|
||||
@@ -4412,7 +4412,7 @@ async def chat_generate_post(request: Request):
|
||||
company_strategy=company_strategy,
|
||||
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
|
||||
critic_result = await orchestrator.critic.process(
|
||||
@@ -4437,7 +4437,7 @@ async def chat_generate_post(request: Request):
|
||||
company_strategy=company_strategy,
|
||||
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(
|
||||
post=post_content,
|
||||
profile_analysis=profile_analysis.full_analysis,
|
||||
@@ -4466,7 +4466,7 @@ async def chat_generate_post(request: Request):
|
||||
profile_analysis=profile_analysis.full_analysis,
|
||||
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
|
||||
import uuid
|
||||
@@ -4597,7 +4597,7 @@ async def chat_refine_post(request: Request):
|
||||
company_strategy=company_strategy,
|
||||
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_result = await orchestrator.critic.process(
|
||||
@@ -4622,7 +4622,7 @@ async def chat_refine_post(request: Request):
|
||||
company_strategy=company_strategy,
|
||||
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(
|
||||
post=refined_post,
|
||||
@@ -4651,7 +4651,7 @@ async def chat_refine_post(request: Request):
|
||||
profile_analysis=full_analysis,
|
||||
example_posts=example_post_texts
|
||||
)
|
||||
refined_post = sanitize_post_content(refined_post)
|
||||
refined_post = sanitize_post_content(refined_post, full_analysis)
|
||||
|
||||
return JSONResponse({
|
||||
"success": True,
|
||||
@@ -4974,7 +4974,7 @@ async def company_chat_generate_post(request: Request):
|
||||
company_strategy=company_strategy,
|
||||
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
|
||||
critic_result = await orchestrator.critic.process(
|
||||
@@ -4998,7 +4998,7 @@ async def company_chat_generate_post(request: Request):
|
||||
company_strategy=company_strategy,
|
||||
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:
|
||||
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,
|
||||
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({
|
||||
"success": True,
|
||||
|
||||
Reference in New Issue
Block a user