diff --git a/src/agents/researcher.py b/src/agents/researcher.py index e6001f6..0b5a891 100644 --- a/src/agents/researcher.py +++ b/src/agents/researcher.py @@ -1,8 +1,10 @@ """Research agent using Perplexity.""" import json import random +import re from datetime import datetime, timedelta, timezone from typing import Dict, Any, List +from urllib.parse import urlparse from loguru import logger from src.agents.base import BaseAgent @@ -11,6 +13,57 @@ from src.agents.base import BaseAgent class ResearchAgent(BaseAgent): """Agent for researching new content topics using Perplexity.""" + SOURCE_TIERS = { + "tier_1_primary": { + "domains": { + "destatis.de", "bundesbank.de", "bmwk.de", "bmas.de", "bmbf.de", + "europa.eu", "ec.europa.eu", "oecd.org", "weforum.org", "ilo.org", + "who.int", "worldbank.org", "imf.org", "stanford.edu", "mit.edu", + "harvard.edu", "ox.ac.uk", "cam.ac.uk", "nature.com", "science.org", + "arxiv.org", "ssrn.com", "bitkom.org", "ifo.de", "iwkoeln.de", + "iab.de", "fraunhofer.de", "acatech.de", "bertelsmann-stiftung.de" + }, + "names": { + "destatis", "bundesbank", "eu-kommission", "european commission", + "oecd", "world economic forum", "stanford", "mit", "harvard", + "nature", "science", "bitkom", "ifo institut", "fraunhofer", + "institut der deutschen wirtschaft", "iab" + }, + "label": "Primärquelle" + }, + "tier_2_editorial": { + "domains": { + "handelsblatt.com", "faz.net", "manager-magazin.de", "wiwo.de", + "sueddeutsche.de", "zeit.de", "spiegel.de", "tagesschau.de", + "reuters.com", "bloomberg.com", "ft.com", "economist.com", + "hbr.org", "mitsloan.mit.edu", "mckinsey.com", "bcg.com", + "bain.com", "gartner.com", "forrester.com", "pwc.com", + "deloitte.com", "kpmg.com", "ey.com", "accenture.com", + "stackoverflow.blog", "stackoverflow.co", "github.blog" + }, + "names": { + "handelsblatt", "frankfurter allgemeine", "faz", "manager magazin", + "wirtschaftswoche", "reuters", "bloomberg", "financial times", + "harvard business review", "mit sloan", "mckinsey", "bcg", + "bain", "gartner", "forrester", "pwc", "deloitte", "kpmg", + "accenture", "stack overflow", "github" + }, + "label": "Renommierte Sekundärquelle" + }, + "tier_3_vendor": { + "domains": { + "microsoft.com", "google.com", "openai.com", "anthropic.com", + "salesforce.com", "sap.com", "hubspot.com", "linkedin.com", + "workday.com", "servicenow.com", "aws.amazon.com", "cloud.google.com" + }, + "names": { + "microsoft", "google", "openai", "anthropic", "salesforce", + "sap", "hubspot", "linkedin", "workday", "servicenow", "aws" + }, + "label": "Unternehmens-/Vendor-Quelle" + } + } + def __init__(self): """Initialize research agent.""" super().__init__("Researcher") @@ -78,10 +131,9 @@ class ResearchAgent(BaseAgent): "Du bist ein Research-Spezialist. Finde aktuelle Studien, Statistiken und News mit Quellenangaben." ] - raw_research = await self.call_perplexity( + raw_research = await self._research_with_provider( system_prompt=random.choice(system_prompts), - user_prompt=perplexity_prompt, - model="sonar-pro" + user_prompt=perplexity_prompt ) logger.info("Step 2: Transforming research into personalized topic ideas") @@ -103,7 +155,7 @@ class ResearchAgent(BaseAgent): system_prompt=self._get_topic_creator_system_prompt(), user_prompt=transform_prompt, model="gpt-4o", - temperature=0.7, # Higher for creative topic angles + temperature=0.35, response_format={"type": "json_object"} ) @@ -111,7 +163,15 @@ class ResearchAgent(BaseAgent): result = json.loads(response) suggested_topics = result.get("topics", []) - # STEP 3: Ensure diversity - filter out similar topics + # STEP 3: Quality gate + diversity + suggested_topics = self._quality_gate_topics( + suggested_topics, + persona=persona, + content_pillars=content_pillars, + target_audience=target_audience, + existing_topics=existing_topics, + example_posts=example_posts or [] + ) suggested_topics = self._ensure_diversity(suggested_topics) # Parse research results @@ -125,6 +185,18 @@ class ResearchAgent(BaseAgent): logger.info(f"Research completed with {len(research_results['suggested_topics'])} topic suggestions") return research_results + async def _research_with_provider(self, system_prompt: str, user_prompt: str) -> str: + """Run live web research through the current provider. + + This method intentionally keeps Perplexity isolated so the provider can be + swapped for Azure/Exa/Tavily/Brave without rewriting topic transformation. + """ + return await self.call_perplexity( + system_prompt=system_prompt, + user_prompt=user_prompt, + model="sonar-pro" + ) + def _get_topic_creator_system_prompt(self) -> str: """Get system prompt for transforming research into personalized topics.""" return """Du bist ein LinkedIn Content-Stratege, der aus Recherche-Ergebnissen KONKRETE, PERSONALISIERTE Themenvorschläge erstellt. @@ -151,6 +223,8 @@ Deine Themenvorschläge müssen: 3. EINEN HOOK VORSCHLAGEN - Eine konkrete Idee wie der Post starten könnte 4. HINTERGRUND-INFOS LIEFERN - Fakten/Daten aus der Recherche die die Person nutzen kann 5. ABWECHSLUNGSREICH SEIN - Verschiedene Formate und Kategorien +6. QUELLENTREU BLEIBEN - Nur Fakten aus den Recherche-Ergebnissen verwenden, keine Zahlen erfinden +7. REDAKTIONELL FILTERN - Wenn Quelle, Datum oder Fakt schwach sind, keinen Themenvorschlag daraus machen Antworte als JSON.""" @@ -272,6 +346,8 @@ Gib deine Antwort im folgenden JSON-Format zurück: if persona: persona_hint = f"\nEXPERTISE DER PERSON: {persona[:600]}\n" + trusted_sources = self._trusted_sources_prompt() + # Randomize the research focus for variety research_angles = [ { @@ -335,12 +411,15 @@ KONTEXT: RECHERCHE-SCHWERPUNKTE FÜR DIESE SESSION: {angles_text} +BEVORZUGTE RENOMMIERTE QUELLEN: +{trusted_sources} + ⛔ BEREITS BEHANDELTE THEMEN - NICHT NOCHMAL VORSCHLAGEN: {existing_text} === DEINE AUFGABE === -Recherchiere FAKTEN, DATEN und ENTWICKLUNGEN - keine fertigen Themenvorschläge! +Recherchiere FAKTEN, DATEN und ENTWICKLUNGEN aus renommierten Quellen - keine fertigen Themenvorschläge! Ich brauche ROHDATEN die ich dann in personalisierte Content-Ideen umwandeln kann. Für jede Entwicklung/News sammle: @@ -348,8 +427,9 @@ Für jede Entwicklung/News sammle: 2. **Zahlen & Daten** - Statistiken, Prozentsätze, Beträge, Veränderungen 3. **Wer ist beteiligt?** - Unternehmen, Personen, Organisationen 4. **Wann?** - Genaues Datum oder Zeitraum -5. **Quelle** - URL oder Publikationsname +5. **Quelle** - URL, Publikationsname und falls möglich Autor/Organisation 6. **Kontext** - Warum ist das relevant? Was bedeutet es? +7. **Quellenqualität** - Primärquelle, renommierte Sekundärquelle oder Vendor/Unternehmensquelle SUCHE NACH: ✅ Neue Studien/Reports mit konkreten Zahlen @@ -364,8 +444,9 @@ FORMAT DEINER ANTWORT: Liefere 8-10 verschiedene Entwicklungen/News mit möglichst vielen Fakten und Zahlen. Formatiere sie klar und strukturiert. -QUALITÄTSKRITERIEN: -✅ AKTUALITÄT: Von dieser Woche oder letzter Woche +QUALITÄTSKRITERIEN (streng): +✅ QUELLE: Bevorzuge Primärquellen, offizielle Reports, renommierte Wirtschafts-/Fachmedien +✅ AKTUALITÄT: Möglichst letzte 30 Tage; bei Studien/Reports maximal 12 Monate, wenn sie substanziell sind ✅ KONKRETHEIT: Echte Zahlen, Namen, Daten (nicht "Experten sagen...") ✅ VERIFIZIERBARKEIT: Echte Quelle die man prüfen kann ✅ BRANCHENRELEVANZ: Spezifisch für {industry} @@ -374,7 +455,9 @@ QUALITÄTSKRITERIEN: - Vage Aussagen ohne Daten ("KI wird wichtiger") - Generische Trends ohne konkreten Aufhänger - Alte News die jeder schon kennt -- Themen ohne verifizierbare Fakten""" +- Themen ohne verifizierbare Fakten +- SEO-Listen, Content-Farmen, reine Meinungsblogs ohne Primärdaten +- Quellen, die nur andere Quellen zusammenfassen und keinen eigenen Beleg liefern""" def _get_transform_prompt( self, @@ -479,9 +562,17 @@ FORMAT DER THEMENVORSCHLÄGE: "category": "Meinung/Take | Erfahrungsbericht | Konträr | How-To/Insight | Story | Analyse", "angle": "Der spezifische Blickwinkel/die Perspektive für diesen Post", "hook_idea": "Konkrete Hook-Idee die zum Post passen würde (1-2 Sätze)", + "fact": "Kurzer, konkreter Kernfakt für die bestehende Topic-Auswahl in der UI", + "relevance": "Warum ist das für die Zielgruppe relevant?", "key_facts": ["Fakt 1 aus der Recherche", "Fakt 2 mit Zahlen", "Fakt 3"], "why_this_person": "Warum passt dieses Thema zu DIESER Person und ihrer Expertise?", - "source": "Quellenangabe" + "source": "Konkrete Quellenangabe mit URL oder Publikationsname", + "source_title": "Titel der Quelle / Studie / Meldung", + "source_domain": "Domain falls URL vorhanden", + "published_at": "Publikationsdatum oder Zeitraum falls bekannt", + "evidence": ["Beleg 1 aus Quelle", "Beleg 2 aus Quelle"], + "source_quality": "Primärquelle | Renommierte Sekundärquelle | Vendor/Unternehmensquelle", + "fit_reason": "Warum ist der Angle für diese Person glaubwürdig?" }} ] }} @@ -492,16 +583,220 @@ BEISPIEL EINES GUTEN THEMENVORSCHLAGS: "category": "Erfahrungsbericht", "angle": "Persönliche Erfahrung eines Tech-Leads mit der Veränderung seiner Rolle durch KI", "hook_idea": "Vor einem Jahr habe ich Code geschrieben. Heute schreibe ich Prompts. Und ehrlich? Ich weiß noch nicht ob das gut oder schlecht ist.", + "fact": "KI-Tools verändern Entwicklungsarbeit messbar: viele Teams sparen Zeit beim Coden, investieren aber mehr in Review und Qualitätssicherung.", + "relevance": "Für technische Führungskräfte ist nicht nur Produktivität relevant, sondern wie sich Verantwortung, Review-Prozesse und Teamrollen verändern.", "key_facts": ["GitHub Copilot wird von 92% der Entwickler genutzt (Stack Overflow 2024)", "Durchschnittliche Zeitersparnis: 55%", "Aber: Code-Review-Zeit +40%"], "why_this_person": "Als Tech-Lead hat die Person direkten Einblick in diese Veränderung und kann authentisch darüber berichten", - "source": "Stack Overflow Developer Survey 2024" + "source": "Stack Overflow Developer Survey 2024", + "source_title": "Stack Overflow Developer Survey 2024", + "source_domain": "stackoverflow.co", + "published_at": "2024", + "evidence": ["92% der Entwickler nutzen KI-Tools oder planen es", "Konkrete Aussagen zur Nutzung von KI im Entwicklungsworkflow"], + "source_quality": "Primärquelle", + "fit_reason": "Direkter Bezug zu Rolle, Teamalltag und technischen Entscheidungen" }} WICHTIG: - Jeder Vorschlag muss sich UNTERSCHEIDEN (anderer Angle, andere Kategorie) - Keine generischen "Die Zukunft von X" Themen - Hook-Ideen müssen zum Stil der Beispiel-Posts passen! -- Key Facts müssen aus der Recherche stammen (keine erfundenen Zahlen)""" +- Key Facts müssen aus der Recherche stammen (keine erfundenen Zahlen) +- Verwirf Rohdaten mit schwacher Quelle statt sie kreativ umzudeuten +- Jeder Vorschlag braucht eine erkennbare, konkrete Quelle""" + + def _trusted_sources_prompt(self) -> str: + """Return a compact source policy for the research provider prompt.""" + return """Tier 1 bevorzugen: offizielle Reports/Studien/Primärquellen (Destatis, Bundesbank, EU/OECD/WEF/ILO/World Bank/IMF, Universitäten, Fraunhofer, Ifo, Bitkom, Stack Overflow Survey, Unternehmensberichte). +Tier 2 zulässig: renommierte Wirtschafts- und Fachmedien oder Beratungs-/Researchhäuser (Handelsblatt, FAZ, Manager Magazin, WiWo, Reuters, Bloomberg, FT, HBR, MIT Sloan, McKinsey, BCG, Gartner, Forrester, Deloitte, PwC). +Tier 3 nur mit Vorsicht: Vendor-/Unternehmensblogs, wenn sie Primärdaten, Produktankündigungen oder konkrete Reports enthalten. +Nicht verwenden: SEO-Content, generische Trendlisten, Content-Farmen, reine Meinungsblogs ohne überprüfbare Daten.""" + + def _extract_domain(self, source: str) -> str: + """Extract a normalized domain from a source string or URL.""" + if not source: + return "" + urls = re.findall(r"https?://[^\s),\]]+", source) + candidate = urls[0] if urls else source.strip().split()[0] + parsed = urlparse(candidate if "://" in candidate else f"https://{candidate}") + domain = (parsed.netloc or parsed.path).lower().strip("/") + if domain.startswith("www."): + domain = domain[4:] + return domain + + def _domain_matches(self, domain: str, configured_domain: str) -> bool: + configured_domain = configured_domain.lower() + return domain == configured_domain or domain.endswith(f".{configured_domain}") + + def _classify_source(self, topic: Dict[str, Any]) -> Dict[str, str]: + """Classify the source into a source tier using URL/domain and publication names.""" + source_text = " ".join([ + str(topic.get("source", "") or ""), + str(topic.get("source_title", "") or ""), + str(topic.get("source_domain", "") or ""), + ]).lower() + domain = self._extract_domain(source_text) + + for tier, config in self.SOURCE_TIERS.items(): + for configured_domain in config["domains"]: + if domain and self._domain_matches(domain, configured_domain): + return {"source_tier": tier, "source_domain": domain, "source_quality": config["label"]} + for name in config["names"]: + if name in source_text: + return {"source_tier": tier, "source_domain": domain, "source_quality": config["label"]} + + return { + "source_tier": "unverified", + "source_domain": domain, + "source_quality": "Nicht klassifizierte Quelle" + } + + def _keyword_set(self, values: List[Any], limit: int = 80) -> set: + """Extract a compact set of meaningful keywords for heuristics.""" + text_parts: List[str] = [] + for value in values: + if isinstance(value, str): + text_parts.append(value) + elif isinstance(value, list): + text_parts.extend([str(v) for v in value if v]) + text = " ".join(text_parts).lower() + words = re.findall(r"\b[a-zA-ZäöüÄÖÜß][a-zA-ZäöüÄÖÜß\-]{3,}\b", text) + stop_words = { + "dass", "oder", "aber", "eine", "einer", "einem", "einen", "diese", "dieser", + "dieses", "nicht", "auch", "mehr", "über", "unter", "durch", "thema", "werden", + "wurde", "sind", "kann", "sich", "their", "about", "with", "from", "this", + "that", "into", "professionals", "business" + } + result = [] + seen = set() + for word in words: + if word in stop_words or word in seen: + continue + seen.add(word) + result.append(word) + if len(result) >= limit: + break + return set(result) + + def _score_topic_quality( + self, + topic: Dict[str, Any], + persona_keywords: set, + existing_keywords: set + ) -> float: + """Score a topic for source quality, evidence, specificity and person fit.""" + score = 0.0 + tier = topic.get("source_tier", "unverified") + if tier == "tier_1_primary": + score += 5.0 + elif tier == "tier_2_editorial": + score += 4.0 + elif tier == "tier_3_vendor": + score += 2.0 + else: + score -= 2.0 + + text_values = [ + topic.get("title", ""), + topic.get("angle", ""), + topic.get("fact", ""), + topic.get("why_this_person", ""), + topic.get("fit_reason", ""), + topic.get("relevance", ""), + topic.get("key_facts", []), + topic.get("evidence", []), + ] + topic_keywords = self._keyword_set(text_values, limit=80) + overlap = topic_keywords.intersection(persona_keywords) + score += min(len(overlap), 6) * 0.7 + + if topic.get("why_this_person") or topic.get("fit_reason"): + score += 1.5 + if topic.get("angle"): + score += 1.0 + if topic.get("evidence") and isinstance(topic.get("evidence"), list): + score += min(len(topic["evidence"]), 3) * 0.8 + if topic.get("key_facts") and isinstance(topic.get("key_facts"), list): + score += min(len(topic["key_facts"]), 3) * 0.6 + if re.search(r"\d", " ".join([str(v) for v in text_values])): + score += 1.0 + if not topic.get("source"): + score -= 4.0 + if len(topic_keywords.intersection(existing_keywords)) >= 5: + score -= 2.0 + + return round(score, 2) + + def _quality_gate_topics( + self, + topics: List[Dict[str, Any]], + persona: str, + content_pillars: List[str], + target_audience: str, + existing_topics: List[str], + example_posts: List[str] + ) -> List[Dict[str, Any]]: + """Normalize, score and filter researched topics before they reach the UI.""" + if not isinstance(topics, list): + return [] + + persona_keywords = self._keyword_set( + [persona, content_pillars, target_audience, example_posts[:5]], + limit=120 + ) + existing_keywords = self._keyword_set(existing_topics[:30], limit=160) + + processed: List[Dict[str, Any]] = [] + seen_titles = set() + for topic in topics: + if not isinstance(topic, dict): + continue + title = str(topic.get("title", "") or "").strip() + if not title: + continue + title_key = re.sub(r"\s+", " ", title.lower()) + if title_key in seen_titles: + continue + seen_titles.add(title_key) + + source_info = self._classify_source(topic) + normalized = dict(topic) + normalized.update({k: v for k, v in source_info.items() if v}) + if not normalized.get("fact") and isinstance(normalized.get("key_facts"), list) and normalized["key_facts"]: + normalized["fact"] = str(normalized["key_facts"][0]) + if not normalized.get("source_quality") or normalized.get("source_quality") == "Nicht klassifizierte Quelle": + normalized["source_quality"] = source_info["source_quality"] + if not normalized.get("source_domain") and source_info.get("source_domain"): + normalized["source_domain"] = source_info["source_domain"] + + quality_score = self._score_topic_quality(normalized, persona_keywords, existing_keywords) + normalized["research_quality_score"] = quality_score + normalized["research_provider"] = "perplexity" + + # Keep strong unknown-source candidates only if they are highly specific and person-fit. + if quality_score >= 3.0: + processed.append(normalized) + + if len(processed) < 4: + logger.warning("Research quality gate found fewer than 4 strong topics; relaxing threshold.") + relaxed = [] + for topic in topics: + if not isinstance(topic, dict) or not topic.get("title"): + continue + source_info = self._classify_source(topic) + normalized = dict(topic) + normalized.update({k: v for k, v in source_info.items() if v}) + if not normalized.get("fact") and isinstance(normalized.get("key_facts"), list) and normalized["key_facts"]: + normalized["fact"] = str(normalized["key_facts"][0]) + normalized["research_quality_score"] = self._score_topic_quality( + normalized, persona_keywords, existing_keywords + ) + normalized["research_provider"] = "perplexity" + relaxed.append(normalized) + processed = sorted(relaxed, key=lambda t: t.get("research_quality_score", 0), reverse=True)[:6] + + processed.sort(key=lambda t: t.get("research_quality_score", 0), reverse=True) + logger.info(f"Research quality gate: {len(topics)} -> {len(processed)} topics") + return processed[:8] def _get_strategy_section(self, company_strategy: Dict[str, Any] = None, strategy_weight: float = 0.5) -> str: """Build company strategy section for research prompt with weighted influence.""" diff --git a/src/agents/writer.py b/src/agents/writer.py index 8302079..739beb3 100644 --- a/src/agents/writer.py +++ b/src/agents/writer.py @@ -1352,7 +1352,7 @@ Gib NUR den fertigen Post zurück.""" post_type: Any = None ) -> List[Dict[str, str]]: """ - Generate 4 different hook options for a topic. + Generate 4 high-quality hook options for a topic. Args: topic: Topic dictionary with title, fact, etc. @@ -1374,11 +1374,21 @@ Gib NUR den fertigen Post zurück.""" # Build example hooks section example_hooks = "" if hook_phrases: - example_hooks = "\n\nBeispiel-Hooks dieser Person (zur Inspiration):\n" + "\n".join([f"- {h}" for h in hook_phrases[:5]]) + example_hooks = "\n\nBeispiel-Hooks dieser Person (Muster erkennen, NICHT kopieren):\n" + "\n".join([f"- {h}" for h in hook_phrases[:8]]) 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) + source_context = "" + if topic.get("source") or topic.get("source_title") or topic.get("source_quality"): + source_context = f""" + +QUELLENKONTEXT: +- Quelle: {topic.get('source_title') or topic.get('source') or 'unbekannt'} +- Quellenqualität: {topic.get('source_quality') or topic.get('source_tier') or 'nicht klassifiziert'} +- Datum/Zeitraum: {topic.get('published_at') or 'nicht angegeben'} +""" + person_fit = topic.get("why_this_person") or topic.get("fit_reason") or topic.get("relevance") or "" 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. @@ -1388,42 +1398,43 @@ STIL DER PERSON: - Ansprache: {address} {example_hooks} {format_card} +{source_context} 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. +- Keine leeren LinkedIn-Templates wie "Niemand spricht darüber", "Unpopular opinion", "Ich muss ehrlich sein", "Das verändert alles". - 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. +- Hook darf nicht klingen wie eine Überschrift, sondern wie ein echter erster Satz der Person. +- Wenn die Person eher sachlich schreibt, keine künstliche Dramatisierung. -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 +GENERIERE 12 VERSCHIEDENE HOOK-KANDIDATEN: +- 2x Pointierte Beobachtung +- 2x Konkreter Daten-/Quellenanker +- 2x Persönliche Einordnung +- 2x Konträrer Take +- 2x Problem-/Spannungsbogen +- 2x Ruhige Expertenbeobachtung 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, aber nur mit echter Substanz +- Die Hooks sollen Aufmerksamkeit erzeugen, 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} +- Nutze bevorzugt einen konkreten Fakt, ein klares Spannungsfeld oder die glaubwürdige Perspektive der Person - 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", "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"}} + {{"hook": "Der Hook-Text hier", "style": "Pointiert", "anchor": "konkreter Fakt/Gedanke dahinter", "fit": "warum passt das zur Person"}}, + {{"hook": "Der Hook-Text hier", "style": "Datenanker", "anchor": "konkreter Fakt/Gedanke dahinter", "fit": "warum passt das zur Person"}} ]}}""" # Build user prompt @@ -1438,6 +1449,10 @@ Antworte im JSON-Format: post_type_section += f"\n{post_type.description}" content_block = topic.get('fact', topic.get('description', 'Keine Details verfügbar')) + if topic.get('angle'): + content_block += f"\n\nANGLE:\n{topic.get('angle')}" + if person_fit: + content_block += f"\n\nWARUM DIESE PERSON:\n{person_fit}" if topic.get('summary'): content_block += f"\n\nZUSAMMENFASSUNG:\n{topic.get('summary')}" if topic.get('extended_summary'): @@ -1446,8 +1461,12 @@ Antworte im JSON-Format: content_block += "\n\nGLIEDERUNG:\n" + "\n".join([f"- {o}" for o in topic.get('outline', [])]) 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', [])]) + if topic.get('key_facts') and isinstance(topic.get('key_facts'), list): + content_block += "\n\nKEY FACTS:\n" + "\n".join([f"- {p}" for p in topic.get('key_facts', [])]) + if topic.get('evidence') and isinstance(topic.get('evidence'), list): + content_block += "\n\nBELEGE:\n" + "\n".join([f"- {p}" for p in topic.get('evidence', [])]) - user_prompt = f"""Generiere 6 Hooks für dieses Thema: + user_prompt = f"""Generiere 12 Hook-Kandidaten für dieses Thema: THEMA: {topic.get('title', 'Unbekanntes Thema')} @@ -1460,7 +1479,9 @@ KERN-FAKT/INHALT: WICHTIGE ANKERBEGRIFFE: {anchor_terms_text} -Generiere jetzt die 6 verschiedenen Hooks im JSON-Format.""" +Wähle keine generischen Einstiege. Jeder Kandidat muss an einem konkreten Fakt, Angle oder persönlichen Gedanken hängen. + +Generiere jetzt die 12 verschiedenen Hooks im JSON-Format.""" response = await self.call_azure_claude( system_prompt=system_prompt, @@ -1541,13 +1562,25 @@ Generiere jetzt die 6 verschiedenen Hooks im JSON-Format.""" generic_penalties = [ "die meisten unterschätzen", + "viele unterschätzen", "was wäre wenn", "verändert alles", "niemand spricht darüber", + "unpopular opinion", + "unpopuläre meinung", + "ich muss ehrlich sein", + "ehrlich gesagt", "lass uns", "stellen sie sich vor", + "stell dir vor", "in der heutigen zeit", "es ist kein geheimnis", + "gamechanger", + "game changer", + "revolutioniert", + "die zukunft von", + "wir müssen reden", + "hier ist warum", ] for phrase in generic_penalties: if phrase in hook_lower: @@ -1559,16 +1592,29 @@ Generiere jetzt die 6 verschiedenen Hooks im JSON-Format.""" overlap += 1 score += min(overlap, 3) * 2.5 + concrete_signals = [ + bool(re.search(r"\d", hook)), + any(char in hook for char in [":", "–", "-"]), + len(re.findall(r"\b[A-ZÄÖÜ][a-zäöüß]{3,}\b", hook)) >= 1, + ] + score += sum(concrete_signals) * 0.6 + if "?" in hook: - score += 0.5 + score += 0.2 if len(hook) < 18: score -= 2.0 - if len(hook) > 220: + if len(hook) > 180: score -= 2.0 if hook.count(".") > 2: score -= 1.5 + if hook_lower.count("ich") > 2: + score -= 0.8 - if style in {"Provokant", "Storytelling", "Fakten-basiert", "Neugier-weckend", "Pointiert", "Reflektiert"}: + if style in { + "Provokant", "Storytelling", "Fakten-basiert", "Neugier-weckend", + "Pointiert", "Reflektiert", "Datenanker", "Persönlich", + "Konträr", "Problem", "Expertenbeobachtung" + }: score += 0.5 return score @@ -1599,7 +1645,25 @@ Generiere jetzt die 6 verschiedenen Hooks im JSON-Format.""" 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]] + + selected: List[Dict[str, Any]] = [] + used_styles = set() + for item in scored: + if len(selected) >= 4: + break + if item["style"] in used_styles and len(scored) >= 6: + continue + used_styles.add(item["style"]) + selected.append(item) + + if len(selected) < 4: + for item in scored: + if item not in selected: + selected.append(item) + if len(selected) >= 4: + break + + return [{"hook": item["hook"], "style": item["style"]} for item in selected[:4]] async def generate_improvement_suggestions( self, diff --git a/src/web/templates/user/company_manage_create.html b/src/web/templates/user/company_manage_create.html index c422cfe..f7c2171 100644 --- a/src/web/templates/user/company_manage_create.html +++ b/src/web/templates/user/company_manage_create.html @@ -719,10 +719,9 @@ function showFinalResult(result) { resultHeader.innerHTML = `
- - ${result.approved ? 'Approved' : 'Review needed'} + + Post erstellt - Score: ${result.final_score}/100 Iterationen: ${result.iterations}
`; @@ -801,44 +800,20 @@ function renderVersions(versions, feedbackList) { class="px-4 py-2 rounded-lg text-sm font-medium transition-colors ${i === currentVersionIndex ? 'bg-brand-highlight text-brand-bg-dark' : 'bg-brand-bg text-gray-300 hover:bg-brand-bg-light'}"> V${i + 1} - ${feedbackList[i] ? `(${feedbackList[i].overall_score || '?'})` : ''} `).join(''); const currentVersion = versions[currentVersionIndex]; - const currentFeedback = feedbackList[currentVersionIndex]; versionsContainer.innerHTML = ` -
+
Version ${currentVersionIndex + 1} - ${currentFeedback ? ` - - ${currentFeedback.approved ? 'Approved' : `Score: ${currentFeedback.overall_score}/100`} - - ` : 'Wird bewertet...'} + Wird erstellt...
${currentVersion}
- ${currentFeedback ? ` -
- Kritik -

${currentFeedback.feedback || 'Keine Kritik'}

- ${currentFeedback.improvements && currentFeedback.improvements.length > 0 ? ` -
- Verbesserungen: -
    - ${currentFeedback.improvements.map(imp => ` -
  • - ${imp} -
  • - `).join('')} -
-
- ` : ''} -
- ` : ''}
`; } diff --git a/src/web/templates/user/company_manage_post_detail.html b/src/web/templates/user/company_manage_post_detail.html index a032787..a336e38 100644 --- a/src/web/templates/user/company_manage_post_detail.html +++ b/src/web/templates/user/company_manage_post_detail.html @@ -115,9 +115,6 @@

{{ post.topic_title or 'Untitled Post' }}

{{ post.created_at.strftime('%d.%m.%Y um %H:%M Uhr') if post.created_at else 'N/A' }} - | - {{ post.iterations }} Iteration{{ 's' if post.iterations != 1 else '' }} - | {{ employee_name }}
@@ -365,20 +362,6 @@ {% endif %} - - {% if style_card %} -
-
-

- - Style Card -

- Quelle: Profilanalyse -
-
{{ style_card }}
-
- {% endif %} - {% if not permissions or permissions.can_schedule_posts %}
@@ -481,10 +464,6 @@ Erstellt {{ post.created_at.strftime('%d.%m.%Y') if post.created_at else 'N/A' }}
-
- Iterationen - {{ post.iterations }} -
Zeichen {{ post.post_content | length }} @@ -737,7 +716,7 @@ async function loadSuggestions() { listEl.innerHTML = ''; const header = document.createElement('p'); header.className = 'text-xs text-gray-500 mb-3'; - header.textContent = 'Basierend auf Kritiker-Feedback:'; + header.textContent = 'Vorschläge:'; listEl.appendChild(header); suggestions.forEach(s => { diff --git a/src/web/templates/user/company_manage_posts.html b/src/web/templates/user/company_manage_posts.html index a3dbc57..2f48800 100644 --- a/src/web/templates/user/company_manage_posts.html +++ b/src/web/templates/user/company_manage_posts.html @@ -10,22 +10,12 @@ onclick="window.location.href='/company/manage/post/{{ post.id }}?employee_id={{ employee_id }}'">

{{ post.topic_title or 'Untitled' }}

- {% if post.critic_feedback and post.critic_feedback | length > 0 and post.critic_feedback[-1].get('overall_score') is not none %} - {% set score = post.critic_feedback[-1].get('overall_score', 0) %} - - {{ score }} - - {% endif %}
{{ post.created_at.strftime('%d.%m.%Y') if post.created_at else 'N/A' }} - - - {{ post.iterations }}x -
{% if post.post_content %}

{{ post.post_content[:150] }}{% if post.post_content | length > 150 %}...{% endif %}

@@ -77,10 +67,6 @@ .post-card-title { font-weight: 500; color: white; margin-bottom: 0.5rem; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } .post-card-meta { display: flex; align-items: center; gap: 0.75rem; font-size: 0.75rem; color: #9ca3af; } .post-card-preview { font-size: 0.8rem; color: #9ca3af; margin-top: 0.75rem; padding-top: 0.75rem; border-top: 1px solid rgba(61, 72, 72, 0.6); display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; line-height: 1.4; } - .score-badge { display: inline-flex; align-items: center; gap: 0.25rem; padding: 0.125rem 0.5rem; border-radius: 9999px; font-size: 0.7rem; font-weight: 600; } - .score-high { background: rgba(34, 197, 94, 0.2); color: #86efac; } - .score-medium { background: rgba(234, 179, 8, 0.2); color: #fde047; } - .score-low { background: rgba(239, 68, 68, 0.2); color: #fca5a5; } .column-draft .kanban-header { border-left: 3px solid #f59e0b; } .column-approved .kanban-header { border-left: 3px solid #3b82f6; } .column-ready .kanban-header { border-left: 3px solid #22c55e; } @@ -174,10 +160,6 @@ onclick="window.location.href='/company/manage/post/{{ post.id }}?employee_id={{ employee_id }}'">

{{ post.topic_title or 'Untitled' }}

- {% if post.critic_feedback and post.critic_feedback | length > 0 and post.critic_feedback[-1].get('overall_score') is not none %} - {% set score = post.critic_feedback[-1].get('overall_score', 0) %} - {{ score }} - {% endif %}
diff --git a/src/web/templates/user/create_post.html b/src/web/templates/user/create_post.html index 75d2a37..0c9ea3d 100644 --- a/src/web/templates/user/create_post.html +++ b/src/web/templates/user/create_post.html @@ -764,10 +764,9 @@ function showFinalResult(result) { resultHeader.innerHTML = `
- - ${result.approved ? 'Approved' : 'Review needed'} + + Post erstellt - Score: ${result.final_score}/100 Iterationen: ${result.iterations}
`; @@ -852,44 +851,20 @@ function renderVersions(versions, feedbackList) { class="px-4 py-2 rounded-lg text-sm font-medium transition-colors ${i === currentVersionIndex ? 'bg-brand-highlight text-brand-bg-dark' : 'bg-brand-bg text-gray-300 hover:bg-brand-bg-light'}"> V${i + 1} - ${feedbackList[i] ? `(${feedbackList[i].overall_score || '?'})` : ''} `).join(''); const currentVersion = versions[currentVersionIndex]; - const currentFeedback = feedbackList[currentVersionIndex]; versionsContainer.innerHTML = ` -
+
Version ${currentVersionIndex + 1} - ${currentFeedback ? ` - - ${currentFeedback.approved ? 'Approved' : `Score: ${currentFeedback.overall_score}/100`} - - ` : 'Wird bewertet...'} + Wird erstellt...
${currentVersion}
- ${currentFeedback ? ` -
- Kritik -

${currentFeedback.feedback || 'Keine Kritik'}

- ${currentFeedback.improvements && currentFeedback.improvements.length > 0 ? ` -
- Verbesserungen: -
    - ${currentFeedback.improvements.map(imp => ` -
  • - ${imp} -
  • - `).join('')} -
-
- ` : ''} -
- ` : ''}
`; } diff --git a/src/web/templates/user/create_post_file.html b/src/web/templates/user/create_post_file.html index e9683e2..dc01b35 100644 --- a/src/web/templates/user/create_post_file.html +++ b/src/web/templates/user/create_post_file.html @@ -796,10 +796,9 @@ function showFinalResult(result) { resultHeader.innerHTML = `
- - ${result.approved ? 'Approved' : 'Review needed'} + + Post erstellt - Score: ${result.final_score}/100 Iterationen: ${result.iterations}
`; @@ -885,44 +884,20 @@ function renderVersions(versions, feedbackList) { class="px-4 py-2 rounded-lg text-sm font-medium transition-colors ${i === currentVersionIndex ? 'bg-brand-highlight text-brand-bg-dark' : 'bg-brand-bg text-gray-300 hover:bg-brand-bg-light'}"> V${i + 1} - ${feedbackList[i] ? `(${feedbackList[i].overall_score || '?'})` : ''} `).join(''); const currentVersion = versions[currentVersionIndex]; - const currentFeedback = feedbackList[currentVersionIndex]; versionsContainer.innerHTML = ` -
+
Version ${currentVersionIndex + 1} - ${currentFeedback ? ` - - ${currentFeedback.approved ? 'Approved' : `Score: ${currentFeedback.overall_score}/100`} - - ` : 'Wird bewertet...'} + Wird erstellt...
${currentVersion}
- ${currentFeedback ? ` -
- Kritik -

${currentFeedback.feedback || 'Keine Kritik'}

- ${currentFeedback.improvements && currentFeedback.improvements.length > 0 ? ` -
- Verbesserungen: -
    - ${currentFeedback.improvements.map(imp => ` -
  • - ${imp} -
  • - `).join('')} -
-
- ` : ''} -
- ` : ''}
`; } diff --git a/src/web/templates/user/create_post_link.html b/src/web/templates/user/create_post_link.html index 46c5280..7b8dfa5 100644 --- a/src/web/templates/user/create_post_link.html +++ b/src/web/templates/user/create_post_link.html @@ -807,10 +807,9 @@ function showFinalResult(result) { resultHeader.innerHTML = `
- - ${result.approved ? 'Approved' : 'Review needed'} + + Post erstellt - Score: ${result.final_score}/100 Iterationen: ${result.iterations}
`; @@ -898,44 +897,20 @@ function renderVersions(versions, feedbackList) { class="px-4 py-2 rounded-lg text-sm font-medium transition-colors ${i === currentVersionIndex ? 'bg-brand-highlight text-brand-bg-dark' : 'bg-brand-bg text-gray-300 hover:bg-brand-bg-light'}"> V${i + 1} - ${feedbackList[i] ? `(${feedbackList[i].overall_score || '?'})` : ''} `).join(''); const currentVersion = versions[currentVersionIndex]; - const currentFeedback = feedbackList[currentVersionIndex]; versionsContainer.innerHTML = ` -
+
Version ${currentVersionIndex + 1} - ${currentFeedback ? ` - - ${currentFeedback.approved ? 'Approved' : `Score: ${currentFeedback.overall_score}/100`} - - ` : 'Wird bewertet...'} + Wird erstellt...
${currentVersion}
- ${currentFeedback ? ` -
- Kritik -

${currentFeedback.feedback || 'Keine Kritik'}

- ${currentFeedback.improvements && currentFeedback.improvements.length > 0 ? ` -
- Verbesserungen: -
    - ${currentFeedback.improvements.map(imp => ` -
  • - ${imp} -
  • - `).join('')} -
-
- ` : ''} -
- ` : ''}
`; } diff --git a/src/web/templates/user/post_detail.html b/src/web/templates/user/post_detail.html index d225c8f..43edd39 100644 --- a/src/web/templates/user/post_detail.html +++ b/src/web/templates/user/post_detail.html @@ -502,38 +502,16 @@
{{ post.created_at.strftime('%d.%m.%Y um %H:%M Uhr') if post.created_at else 'N/A' }} - | - {{ post.iterations }} Iteration{{ 's' if post.iterations != 1 else '' }}
{{ post.status | capitalize }} - {% if final_feedback and final_feedback.get('overall_score') is not none %} - - Score: {{ final_feedback.get('overall_score', 0) }}/100 - - {% endif %}
- -
- -
-
@@ -778,20 +756,6 @@
- - {% if style_card %} -
-
-

- - Style Card -

- Quelle: Profilanalyse -
-
{{ style_card }}
-
- {% endif %} -

@@ -844,41 +808,8 @@

- +
- - {% if final_feedback and final_feedback.get('overall_score') is not none %} -
-

- - Gesamt-Score -

-
{{ final_feedback.get('overall_score', 0) }}/100
-
- {% endif %} - - - - {% if final_feedback %} -
-

- - Feedback -

-

{{ final_feedback.feedback }}

- {% if final_feedback.strengths %} -
- Stärken -
    - {% for s in final_feedback.strengths %} -
  • + {{ s }}
  • - {% endfor %} -
-
- {% endif %} -
- {% endif %} -

@@ -930,155 +861,17 @@

- -
-
-
-

- - Iterationen -

- -
- - - 1 / {{ post.writer_versions | length if post.writer_versions else 0 }} - - -
-
- - -
- {% if post.writer_versions and post.writer_versions | length > 0 %} - {% for i in range(post.writer_versions | length) %} -
-
- Version {{ i + 1 }} - {% if post.critic_feedback and i < post.critic_feedback | length %} -
- {% if post.critic_feedback[i].get('overall_score') is not none %} - - Score: {{ post.critic_feedback[i].get('overall_score', 0) }}/100 - - {% endif %} - {% if post.critic_feedback[i].get('approved') %} - Approved - {% endif %} -
- {% endif %} -
- -
-
{{ post.writer_versions[i] }}
-
- - {% if post.critic_feedback and i < post.critic_feedback | length %} - {% set fb = post.critic_feedback[i] %} -
-

- - Critic Feedback -

-

{{ fb.feedback }}

-
- {% if fb.strengths %} -
- Stärken -
    - {% for s in fb.strengths %} -
  • + {{ s }}
  • - {% endfor %} -
-
- {% endif %} - {% if fb.improvements %} -
- Verbesserungen -
    - {% for imp in fb.improvements %} -
  • - {{ imp }}
  • - {% endfor %} -
-
- {% endif %} -
-
- {% endif %} -
- {% endfor %} - {% else %} -

Noch keine Versionen vorhanden.

- {% endif %} -
-
-
{% endblock %} {% block scripts %}