diff --git a/.codex b/.codex new file mode 100644 index 0000000..e69de29 diff --git a/requirements.txt b/requirements.txt index a7d9ec1..5c7fb53 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/src/agents/base.py b/src/agents/base.py index 7e9dbb1..5f713fc 100644 --- a/src/agents/base.py +++ b/src/agents/base.py @@ -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, diff --git a/src/agents/critic.py b/src/agents/critic.py index 6a7c18a..4342e70 100644 --- a/src/agents/critic.py +++ b/src/agents/critic.py @@ -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", diff --git a/src/agents/quality_refiner.py b/src/agents/quality_refiner.py index 55686e1..496522a 100644 --- a/src/agents/quality_refiner.py +++ b/src/agents/quality_refiner.py @@ -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 ) diff --git a/src/agents/writer.py b/src/agents/writer.py index eba0491..8302079 100644 --- a/src/agents/writer.py +++ b/src/agents/writer.py @@ -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 ) diff --git a/src/config.py b/src/config.py index cff727f..dd68854 100644 --- a/src/config.py +++ b/src/config.py @@ -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 diff --git a/src/orchestrator.py b/src/orchestrator.py index 6ba4382..e927081 100644 --- a/src/orchestrator.py +++ b/src/orchestrator.py @@ -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"} ) diff --git a/src/tui/app.py b/src/tui/app.py index 5b1ea6e..e8e329d 100644 --- a/src/tui/app.py +++ b/src/tui/app.py @@ -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 ) diff --git a/src/utils/post_cleanup.py b/src/utils/post_cleanup.py index 6cac707..eb7b6b8 100644 --- a/src/utils/post_cleanup.py +++ b/src/utils/post_cleanup.py @@ -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() diff --git a/src/web/templates/admin/statistics.html b/src/web/templates/admin/statistics.html index cf7180c..30e1059 100644 --- a/src/web/templates/admin/statistics.html +++ b/src/web/templates/admin/statistics.html @@ -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)) { diff --git a/src/web/user/routes.py b/src/web/user/routes.py index 0d7fd85..a726c8b 100644 --- a/src/web/user/routes.py +++ b/src/web/user/routes.py @@ -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,