From b3bb67f3ad292542f2362b6376349f7c4e99efa6 Mon Sep 17 00:00:00 2001 From: Ruben Fischer Date: Tue, 17 Mar 2026 15:28:01 +0100 Subject: [PATCH] tried to further improve writing quality --- src/agents/writer.py | 156 ++++++++++- src/orchestrator.py | 259 ++++++++++++++++-- src/web/templates/post_detail.html | 57 ++-- .../templates/user/company_manage_create.html | 31 ++- .../user/company_manage_post_detail.html | 14 + src/web/templates/user/create_post.html | 31 ++- src/web/templates/user/create_post_file.html | 31 ++- src/web/templates/user/create_post_link.html | 31 ++- src/web/templates/user/post_detail.html | 53 ++-- src/web/user/routes.py | 210 +++++++++++++- 10 files changed, 750 insertions(+), 123 deletions(-) diff --git a/src/agents/writer.py b/src/agents/writer.py index 595b98e..eba0491 100644 --- a/src/agents/writer.py +++ b/src/agents/writer.py @@ -263,7 +263,12 @@ class WriterAgent(BaseAgent): num_drafts = min(max(settings.writer_multi_draft_count, 2), 5) # Clamp between 2-5 logger.info(f"Generating {num_drafts} drafts for selection") - system_prompt = self._get_system_prompt(profile_analysis, example_posts, learned_lessons, post_type, post_type_analysis, company_strategy, strategy_weight) + system_prompt = self._get_compact_system_prompt( + profile_analysis=profile_analysis, + example_posts=example_posts, + company_strategy=company_strategy, + strategy_weight=strategy_weight + ) # Generate drafts in parallel with different temperatures/approaches draft_configs = [ @@ -385,8 +390,13 @@ class WriterAgent(BaseAgent): why_section = f"\n**WARUM DU DARÜBER SCHREIBEN SOLLTEST:**\n{topic.get('why_this_person')}\n" plan_section = "" - if topic.get("post_plan"): - plan_section = f"\n**POST-PLAN (VERBINDLICH):**\n{json.dumps(topic.get('post_plan'), ensure_ascii=False, indent=2)}\n" + if topic.get("content_plan"): + plan = topic.get("content_plan") or {} + alignment = plan.get("company_alignment") or [] + alignment_section = "" + if isinstance(alignment, list) and alignment: + alignment_section = "\n**STRATEGIE-ANFORDERUNGEN (VERBINDLICH):**\n" + "\n".join([f"- {a}" for a in alignment]) + "\n" + plan_section = f"\n**CONTENT-PLAN (VERBINDLICH):**\n{json.dumps(plan, ensure_ascii=False, indent=2)}\n{alignment_section}" # User thoughts section thoughts_section = "" @@ -416,6 +426,16 @@ Schreibe einen authentischen LinkedIn-Post, der: 4. Die Key Facts einbaut wo es passt 5. Mit einem passenden CTA endet""" + content_plan_note = "" + if topic.get("content_plan"): + plan = topic.get("content_plan") or {} + tier = plan.get("quality_tier") + required = plan.get("required_elements") or [] + req_text = ", ".join(required[:6]) if isinstance(required, list) else "" + tier_text = f" | Qualitätsziel: {tier}" if tier else "" + req_note = f" | Pflichtelemente: {req_text}" if req_text else "" + content_plan_note = f"\n- CONTENT-PLAN ist verbindlich: Nutze NUR diese Fakten, keine neuen hinzufügen{tier_text}{req_note}" + return f"""Schreibe einen LinkedIn-Post zu folgendem Thema: **THEMA:** {topic.get('title', 'Unbekanntes Thema')} @@ -437,6 +457,7 @@ WICHTIG: - Vermeide KI-typische Formulierungen ("In der heutigen Zeit", "Tauchen Sie ein", etc.) - Schreibe natürlich und menschlich - Der Post soll SOFORT 85+ Punkte im Review erreichen +{content_plan_note} Gib NUR den fertigen Post zurück.""" @@ -556,7 +577,12 @@ Analysiere jeden Entwurf kurz und wähle den besten. Antworte im JSON-Format: elif len(selected_examples) > 3: selected_examples = random.sample(selected_examples, 3) - system_prompt = self._get_system_prompt(profile_analysis, selected_examples, learned_lessons, post_type, post_type_analysis, company_strategy, strategy_weight) + system_prompt = self._get_compact_system_prompt( + profile_analysis=profile_analysis, + example_posts=selected_examples, + company_strategy=company_strategy, + strategy_weight=strategy_weight + ) 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 @@ -570,6 +596,104 @@ Analysiere jeden Entwurf kurz und wähle den besten. Antworte im JSON-Format: logger.info("Post written successfully") return post.strip() + def _build_style_card(self, profile_analysis: Dict[str, Any]) -> str: + """Build a compact, deterministic style card.""" + def _ensure_dict(value): + return value if isinstance(value, dict) else {} + + writing_style = _ensure_dict(profile_analysis.get("writing_style", {})) + linguistic = _ensure_dict(profile_analysis.get("linguistic_fingerprint", {})) + tone_analysis = _ensure_dict(profile_analysis.get("tone_analysis", {})) + phrase_library = _ensure_dict(profile_analysis.get("phrase_library", {})) + 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", {})) + + def _top(items, n): + if not isinstance(items, list): + return [] + return [str(i) for i in items[:n]] + + hook_phrases = _top(phrase_library.get("hook_phrases", []), 3) + cta_phrases = _top(phrase_library.get("cta_phrases", []), 2) + signature_phrases = _top(linguistic.get("signature_phrases", []), 3) + sentence_starters = _top(structure_templates.get("typical_sentence_starters", []), 3) + paragraph_transitions = _top(structure_templates.get("paragraph_transitions", []), 2) + + emoji_usage = visual.get("emoji_usage", {}) + emoji_list = emoji_usage.get("emojis", []) + emoji_str = " ".join(_top(emoji_list, 5)) if isinstance(emoji_list, list) else str(emoji_list) + + return f"""STYLE CARD (kompakt, verbindlich): +- Perspektive: {writing_style.get('perspective', 'Ich')} +- Ansprache: {writing_style.get('form_of_address', 'Du')} +- 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')} +- Hook-Stil Beispiele: {', '.join(hook_phrases) if hook_phrases else 'keine'} +- CTA-Stil Beispiele: {', '.join(cta_phrases) if cta_phrases else 'keine'} +- Signature Phrases: {', '.join(signature_phrases) if signature_phrases 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')} +""" + + def _get_compact_system_prompt( + self, + profile_analysis: Dict[str, Any], + example_posts: List[str] = None, + company_strategy: Optional[Dict[str, Any]] = None, + strategy_weight: float = 0.5 + ) -> str: + """Short, high-signal system prompt to enforce style without overload.""" + style_card = self._build_style_card(profile_analysis) + + examples_section = "" + if example_posts: + sample = example_posts[:2] + examples_section = "\nECHTE BEISPIELE (nur Stil, nicht kopieren):\n" + for i, post in enumerate(sample, 1): + post_text = post[:800] + "..." if len(post) > 800 else post + examples_section += f"\n--- Beispiel {i} ---\n{post_text}\n" + + strategy_section = "" + if company_strategy and strategy_weight >= 0.2: + content_pillars = company_strategy.get("content_pillars", []) + brand_voice = company_strategy.get("brand_voice", "") + tone_guidelines = company_strategy.get("tone_guidelines", "") + dos = company_strategy.get("dos", []) + donts = company_strategy.get("donts", []) + + if strategy_weight >= 0.8: + weight_note = "KRITISCH: Strategie dominiert Inhalt und Stil" + elif strategy_weight >= 0.6: + weight_note = "WICHTIG: Strategie klar erkennbar" + elif strategy_weight >= 0.4: + weight_note = "MODERAT: Strategie subtil einweben" + else: + weight_note = "LEICHT: Strategie nur als Kontext" + + strategy_section = f""" +UNTERNEHMENSKONTEXT ({weight_note}): +- Brand Voice: {brand_voice} +- Tone Guidelines: {tone_guidelines} +- Content Pillars: {', '.join(content_pillars) if content_pillars else 'keine'} +- Do: {', '.join(dos[:3]) if isinstance(dos, list) and dos else 'keine'} +- Don't: {', '.join(donts[:3]) if isinstance(donts, list) and donts else 'keine'} +""" + + return f"""Du bist Ghostwriter für LinkedIn. Schreibe einen Post, der exakt wie die Person klingt. +{style_card} +{strategy_section} +{examples_section} +REGELN: +- Inhalt strikt an den Content-Plan halten, keine neuen Fakten erfinden +- Perspektive und Ansprache strikt einhalten +- Keine KI-Phrasen ("In der heutigen Zeit", "Stellen Sie sich vor", etc.) +- Keine Markdown-Fettung (kein **), keine Trennlinien (---) +- Ausgabe: Nur der fertige Post""" + def _get_system_prompt( self, profile_analysis: Dict[str, Any], @@ -991,8 +1115,13 @@ Strategy Weight: {strategy_weight:.1f} / 1.0 score_text = f"**AKTUELLER SCORE:** {critic_result.get('overall_score', 'N/A')}/100\n\n" if critic_result else "" plan_section = "" - if topic.get("post_plan"): - plan_section = f"\n**POST-PLAN (VERBINDLICH):**\n{json.dumps(topic.get('post_plan'), ensure_ascii=False, indent=2)}\n" + if topic.get("content_plan"): + plan = topic.get("content_plan") or {} + alignment = plan.get("company_alignment") or [] + alignment_section = "" + if isinstance(alignment, list) and alignment: + alignment_section = "\n**STRATEGIE-ANFORDERUNGEN (VERBINDLICH):**\n" + "\n".join([f"- {a}" for a in alignment]) + "\n" + plan_section = f"\n**CONTENT-PLAN (VERBINDLICH):**\n{json.dumps(plan, ensure_ascii=False, indent=2)}\n{alignment_section}" return f"""ÜBERARBEITE den Post basierend auf dem Feedback. @@ -1059,8 +1188,8 @@ Gib NUR den überarbeiteten Post zurück - keine Kommentare.""" source_section = f"\n**QUELLE:** {source_title} {source_url}\n" plan_section = "" - if topic.get("post_plan"): - plan_section = f"\n**POST-PLAN (VERBINDLICH):**\n{json.dumps(topic.get('post_plan'), ensure_ascii=False, indent=2)}\n" + if topic.get("content_plan"): + plan_section = f"\n**CONTENT-PLAN (VERBINDLICH):**\n{json.dumps(topic.get('content_plan'), ensure_ascii=False, indent=2)}\n" # User thoughts section thoughts_section = "" @@ -1090,6 +1219,16 @@ Schreibe einen authentischen LinkedIn-Post, der: 4. Die Key Facts verwendet wo es passt 5. Mit einem passenden CTA endet""" + content_plan_note = "" + if topic.get("content_plan"): + plan = topic.get("content_plan") or {} + tier = plan.get("quality_tier") + required = plan.get("required_elements") or [] + req_text = ", ".join(required[:6]) if isinstance(required, list) else "" + tier_text = f" | Qualitätsziel: {tier}" if tier else "" + req_note = f" | Pflichtelemente: {req_text}" if req_text else "" + content_plan_note = f"\n- CONTENT-PLAN ist verbindlich: Nutze NUR diese Fakten, keine neuen hinzufügen{tier_text}{req_note}" + return f"""Schreibe einen LinkedIn-Post zu folgendem Thema: **THEMA:** {topic.get('title', 'Unbekanntes Thema')} @@ -1108,6 +1247,7 @@ WICHTIG: - Vermeide KI-typische Formulierungen ("In der heutigen Zeit", "Tauchen Sie ein", etc.) - Schreibe natürlich und menschlich - Der Post soll SOFORT 85+ Punkte im Review erreichen +{content_plan_note} Gib NUR den fertigen Post zurück.""" diff --git a/src/orchestrator.py b/src/orchestrator.py index b2043c3..6ba4382 100644 --- a/src/orchestrator.py +++ b/src/orchestrator.py @@ -48,6 +48,51 @@ class WorkflowOrchestrator: ] logger.info("WorkflowOrchestrator initialized with quality check & refiner agents") + def _get_effective_strategy_weight( + self, + base_weight: float, + post_type: Optional[Any] = None, + post_type_analysis: Optional[Dict[str, Any]] = None + ) -> float: + """ + Adjust strategy weight based on post type semantics. + Heuristic: company/announcement types increase, personal/story types decrease. + """ + weight = float(base_weight or 0.0) + + text_parts: List[str] = [] + if post_type: + text_parts.append(str(getattr(post_type, "name", "") or "")) + text_parts.append(str(getattr(post_type, "description", "") or "")) + if post_type_analysis and isinstance(post_type_analysis, dict): + content_focus = post_type_analysis.get("content_focus") or {} + if isinstance(content_focus, dict): + text_parts.append(" ".join(content_focus.get("main_themes", []) or [])) + text_parts.append(str(content_focus.get("value_proposition", "") or "")) + writing_guidelines = post_type_analysis.get("writing_guidelines") or {} + if isinstance(writing_guidelines, dict): + text_parts.append(" ".join(writing_guidelines.get("dos", []) or [])) + text_parts.append(" ".join(writing_guidelines.get("donts", []) or [])) + hooks = post_type_analysis.get("hooks") or {} + if isinstance(hooks, dict): + text_parts.append(" ".join(hooks.get("hook_types", []) or [])) + + text = " ".join([t for t in text_parts if t]).lower() + + boost = 0.0 + if any(k in text for k in ["announcement", "launch", "produkt", "product", "case study", "update", "news", "company", "unternehmen"]): + boost += 0.1 + if any(k in text for k in ["personal", "story", "anekdote", "opinion", "lessons", "behind the scenes", "fehler", "learned"]): + boost -= 0.1 + + # Clamp adjustment for extreme weights + if weight >= 0.8: + boost = max(boost, -0.05) + if weight <= 0.3: + boost = min(boost, 0.05) + + return max(0.0, min(1.0, weight + boost)) + def _set_tracking(self, operation: str, user_id: Optional[str] = None, company_id: Optional[str] = None): """Set tracking context on all agents.""" @@ -56,6 +101,20 @@ class WorkflowOrchestrator: for agent in self._all_agents: agent.set_tracking_context(operation=operation, user_id=uid, company_id=comp_id) + def _normalize_critic_scores(self, critic_result: Dict[str, Any]) -> Dict[str, Any]: + """Ensure overall_score equals the sum of category scores.""" + scores = critic_result.get("scores") or {} + if not isinstance(scores, dict): + return critic_result + total = ( + int(scores.get("authenticity_and_style", 0)) + + int(scores.get("content_quality", 0)) + + int(scores.get("logic_and_coherence", 0)) + + int(scores.get("technical_execution", 0)) + ) + critic_result["overall_score"] = total + return critic_result + async def _resolve_tracking_ids(self, user_id: UUID) -> dict: """Resolve company_id from a user_id for tracking.""" try: @@ -600,8 +659,13 @@ class WorkflowOrchestrator: if post_type: if post_type.analysis: post_type_analysis = post_type.analysis - strategy_weight = post_type.strategy_weight # Extract strategy weight from post type - logger.info(f"Using post type '{post_type.name}' with strategy weight {strategy_weight:.1f}") + strategy_weight = post_type.strategy_weight # Base strategy weight from post type + strategy_weight = self._get_effective_strategy_weight( + base_weight=strategy_weight, + post_type=post_type, + post_type_analysis=post_type_analysis + ) + logger.info(f"Using post type '{post_type.name}' with strategy weight {strategy_weight:.2f}") # Load user's real posts as style examples # If post_type_id is specified, only use posts of that type @@ -639,8 +703,8 @@ class WorkflowOrchestrator: company_strategy = company.company_strategy logger.info(f"Loaded company strategy for post creation: {company.name}") - # Build a structured post plan (used to guide writing) - post_plan = await self._build_post_plan( + # Build a structured content plan (content only, used to guide writing) + content_plan = await self._build_content_plan( topic=topic, profile_analysis=profile_analysis.full_analysis, company_strategy=company_strategy, @@ -649,8 +713,12 @@ class WorkflowOrchestrator: user_thoughts=user_thoughts, post_type_analysis=post_type_analysis ) - if post_plan: - topic = {**topic, "post_plan": post_plan} + if content_plan: + topic = { + **topic, + "content_plan": content_plan, + "content_quality": content_plan.get("quality_tier") + } # Writer-Critic loop while iteration < max_iterations and not approved: @@ -710,6 +778,7 @@ class WorkflowOrchestrator: iteration=iteration, max_iterations=max_iterations ) + critic_result = self._normalize_critic_scores(critic_result) # Style/Strategy scoring (enforce thresholds based on strategy_weight) style_strategy = await self._score_style_strategy( @@ -818,6 +887,10 @@ class WorkflowOrchestrator: # Save generated post from src.database.models import GeneratedPost + metadata = { + "content_plan": content_plan, + "strategy_weight_used": strategy_weight + } generated_post = GeneratedPost( user_id=user_id, topic_title=topic.get("title", "Unknown"), @@ -826,7 +899,8 @@ class WorkflowOrchestrator: writer_versions=writer_versions, critic_feedback=critic_feedback_list, status=status, - post_type_id=post_type_id + post_type_id=post_type_id, + metadata=metadata ) saved_post = await db.save_generated_post(generated_post) @@ -884,7 +958,102 @@ class WorkflowOrchestrator: "readability_check": readability_result } - async def _build_post_plan( + def _derive_content_quality( + self, + topic: Dict[str, Any], + user_thoughts: str = "", + strategy_weight: float = 0.5, + post_type_analysis: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: + """Derive a deterministic content quality tier and requirements.""" + key_facts = topic.get("key_facts") or [] + if not isinstance(key_facts, list): + key_facts = [] + has_summary = bool(topic.get("summary")) + has_extended_summary = bool(topic.get("extended_summary")) + thoughts_len = len((user_thoughts or "").strip()) + + score = 0 + score += 2 if has_extended_summary else 0 + score += 1 if has_summary else 0 + if len(key_facts) >= 3: + score += 2 + elif len(key_facts) >= 2: + score += 1 + if thoughts_len >= 200: + score += 2 + elif thoughts_len >= 80: + score += 1 + + if score >= 5: + tier = "premium" + required = [ + "3 klare Kernpunkte", + "1 Gegenpunkt oder Einwand + kurze Einordnung", + "1 Mini-Story oder Beispiel", + "mind. 1 Fakt/Zahl/Zitat (falls vorhanden)", + "CTA als Diskussionsfrage" + ] + elif score >= 3: + tier = "standard" + required = [ + "3 klare Kernpunkte", + "1 Beispiel oder Anwendung", + "1 klarer Takeaway", + "CTA als Frage oder Aufforderung" + ] + else: + tier = "basic" + required = [ + "1 zentrale These", + "2 Kernpunkte", + "1 klarer Takeaway", + "CTA als Frage" + ] + + if thoughts_len > 0: + required.append("Persönliche Gedanken der Person integrieren") + required.append("Eigener Abschnitt: 'Meine Gedanken dazu' oder ähnliche Formulierung") + + # Post type influence: ensure content format alignment when available + post_type_required = [] + if post_type_analysis and isinstance(post_type_analysis, dict): + hooks = post_type_analysis.get("hooks") or {} + patterns = [] + if isinstance(hooks, dict): + patterns = hooks.get("hook_types") or [] + if patterns: + post_type_required.append(f"Hook-Typ bevorzugt: {patterns[0]}") + formats = post_type_analysis.get("content_formats") or post_type_analysis.get("formats") or [] + if isinstance(formats, list) and formats: + post_type_required.append(f"Format-Orientierung: {formats[0]}") + + # Strategy influence: enforce alignment progressively + strategy_required = [] + if strategy_weight >= 0.8: + strategy_required = [ + "Mindestens 2 Content-Pillars integrieren", + "Brand Voice explizit erkennbar" + ] + elif strategy_weight >= 0.6: + strategy_required = [ + "Mindestens 1 Content-Pillar integrieren", + "Marken-Ton subtil erkennbar" + ] + elif strategy_weight >= 0.4: + strategy_required = [ + "1 Pillar ODER 1 Do berücksichtigen" + ] + + combined_required = required + post_type_required + strategy_required + + return { + "quality_tier": tier, + "required_elements": combined_required, + "strategy_required": strategy_required + } + + async def _build_content_plan( self, topic: Dict[str, Any], profile_analysis: Dict[str, Any], @@ -894,28 +1063,44 @@ class WorkflowOrchestrator: user_thoughts: str = "", post_type_analysis: Optional[Dict[str, Any]] = None ) -> Optional[Dict[str, Any]]: - """Create a structured plan to guide the writer.""" + """Create a structured content plan (content only, no style).""" try: + quality = self._derive_content_quality( + topic, + user_thoughts=user_thoughts, + strategy_weight=strategy_weight, + post_type_analysis=post_type_analysis + ) + company_alignment_requirements: List[str] = [] + if company_strategy and strategy_weight >= 0.4: + content_pillars = company_strategy.get("content_pillars") or [] + if isinstance(content_pillars, list) and content_pillars: + if strategy_weight >= 0.8: + company_alignment_requirements.append(f"Mindestens 2 dieser Pillars nutzen: {', '.join(content_pillars[:3])}") + else: + company_alignment_requirements.append(f"Mindestens 1 dieser Pillars nutzen: {', '.join(content_pillars[:3])}") + brand_voice = company_strategy.get("brand_voice") + if brand_voice and strategy_weight >= 0.6: + company_alignment_requirements.append(f"Brand Voice erkennbar: {brand_voice}") + dos = company_strategy.get("dos") or [] + if isinstance(dos, list) and dos and strategy_weight >= 0.6: + company_alignment_requirements.append(f"Mindestens 1 Do berücksichtigen: {', '.join(dos[:2])}") system_prompt = ( - "Du erstellst einen kompakten Schreibplan für einen LinkedIn-Post. " - "Der Plan muss Stil der Person UND Unternehmensstrategie berücksichtigen. " - "Antwort nur als JSON." + "Du erstellst einen kompakten CONTENT-PLAN für einen LinkedIn-Post. " + "Fokus NUR auf Inhalt, Logik, Fakten und Struktur. " + "KEINE Stil-Anweisungen. Antwort nur als JSON." ) user_prompt = f""" Thema: {topic.get('title')} Kern-Fakt: {topic.get('fact') or topic.get('summary','')} Relevanz: {topic.get('relevance')} +Zusammenfassung: {topic.get('summary') or topic.get('extended_summary','')} +Key Facts: {topic.get('key_facts') or []} Ausgewählter Hook: {selected_hook or "keiner"} Persönliche Gedanken: {user_thoughts or "keine"} -Profil-Analyse (Kurz): -Tone: {profile_analysis.get('writing_style', {}).get('tone')} -Perspektive: {profile_analysis.get('writing_style', {}).get('perspective')} -Ansprache: {profile_analysis.get('writing_style', {}).get('form_of_address')} -Signature Phrases: {profile_analysis.get('linguistic_fingerprint', {}).get('signature_phrases', [])} - Unternehmensstrategie (Kurz): Mission: {company_strategy.get('mission') if company_strategy else ''} Brand Voice: {company_strategy.get('brand_voice') if company_strategy else ''} @@ -924,14 +1109,22 @@ Do: {company_strategy.get('dos') if company_strategy else []} Don't: {company_strategy.get('donts') if company_strategy else []} Strategy Weight: {strategy_weight:.1f} +Qualitätsziel: {quality.get('quality_tier')} +Pflichtelemente: {quality.get('required_elements')} +Strategie-Anforderungen (verbindlich falls vorhanden): {company_alignment_requirements or 'keine'} Gib JSON im Format: {{ - "hook_type": "Story/Fakten/These/Frage/...", - "structure": ["Hook", "Body", "CTA"], - "style_requirements": ["2-4 konkrete Stil-Elemente/Signaturphrasen"], - "strategy_requirements": ["2-4 Strategie-Elemente/Content-Pillar/Do"], - "cta_type": "Frage/Aufforderung/Diskussion/..." + "quality_tier": "basic/standard/premium", + "required_elements": ["..."], + "hook_idea": "1-2 Sätze (Inhaltlich, nicht stilistisch)", + "thesis": "Kernaussage in einem Satz", + "key_points": ["Punkt 1", "Punkt 2", "Punkt 3"], + "example_or_story": "Kurzes Beispiel oder Mini-Story zur Stützung", + "cta": "Konkreter CTA (Frage oder Aufforderung)", + "structure": ["Hook", "These", "Punkt 1", "Punkt 2", "Punkt 3", "Beispiel", "CTA"], + "constraints": ["Keine neuen Fakten erfinden", "Fakten müssen aus Quelle oder user_thoughts kommen"], + "company_alignment": ["Erforderliche Strategy-Elemente aus strategy_required falls vorhanden"] }} """ raw = await self.writer.call_openai( @@ -941,7 +1134,21 @@ Gib JSON im Format: temperature=0.2, response_format={"type": "json_object"} ) - return json.loads(raw) + plan = json.loads(raw) + # Ensure required fields exist + if "quality_tier" not in plan: + plan["quality_tier"] = quality.get("quality_tier") + if "required_elements" not in plan: + plan["required_elements"] = quality.get("required_elements") + if "company_alignment" not in plan and quality.get("strategy_required"): + plan["company_alignment"] = quality.get("strategy_required") + if company_alignment_requirements: + existing = plan.get("company_alignment") or [] + if not isinstance(existing, list): + existing = [] + merged = existing + [r for r in company_alignment_requirements if r not in existing] + plan["company_alignment"] = merged + return plan except Exception as e: logger.warning(f"Post plan generation failed: {e}") return None @@ -1005,8 +1212,8 @@ Gib JSON im Format: user_thoughts: str = "", post_type_analysis: Optional[Dict[str, Any]] = None ) -> Optional[Dict[str, Any]]: - """Public wrapper to build a post plan.""" - return await self._build_post_plan( + """Public wrapper to build a content plan.""" + return await self._build_content_plan( topic=topic, profile_analysis=profile_analysis, company_strategy=company_strategy, diff --git a/src/web/templates/post_detail.html b/src/web/templates/post_detail.html index 041d2d7..d67d770 100644 --- a/src/web/templates/post_detail.html +++ b/src/web/templates/post_detail.html @@ -422,48 +422,14 @@
- - {% if final_feedback and final_feedback.scores %} + + {% if final_feedback and final_feedback.get('overall_score') is not none %}
-

+

- Score-Aufschlüsselung + Gesamt-Score

-
-
-
- Authentizität & Stil - {{ final_feedback.scores.authenticity_and_style }}/40 -
-
-
-
-
-
-
- Content-Qualität - {{ final_feedback.scores.content_quality }}/35 -
-
-
-
-
-
-
- Technische Umsetzung - {{ final_feedback.scores.technical_execution }}/25 -
-
-
-
-
-
-
- Gesamt - {{ final_feedback.overall_score }}/100 -
-
-
+
{{ final_feedback.overall_score }}/100
{% endif %} @@ -488,6 +454,19 @@
{% endif %} + {% if style_card %} +
+
+

+ + Style Card +

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

Aktionen

diff --git a/src/web/templates/user/company_manage_create.html b/src/web/templates/user/company_manage_create.html index 7535424..c422cfe 100644 --- a/src/web/templates/user/company_manage_create.html +++ b/src/web/templates/user/company_manage_create.html @@ -464,6 +464,31 @@ function renderTopicsList(data) { let mediaRecorder = null; let audioChunks = []; +function getMicErrorMessage(error) { + if (!error || !error.name) return 'Mikrofon-Zugriff fehlgeschlagen.'; + if (error.name === 'NotAllowedError') return 'Mikrofon-Zugriff verweigert. Bitte im Browser erlauben.'; + if (error.name === 'NotFoundError') return 'Kein Mikrofon gefunden.'; + if (error.name === 'NotReadableError') return 'Mikrofon wird bereits verwendet.'; + if (error.name === 'SecurityError') return 'Mikrofon funktioniert nur über HTTPS.'; + if (error.name === 'NotSupportedError') return 'Mikrofon wird in diesem Browser nicht unterstützt.'; + return `Mikrofon-Fehler: ${error.name}`; +} + +async function updateMicPermissionStatus() { + if (!navigator.permissions || !navigator.permissions.query) return; + try { + const status = await navigator.permissions.query({ name: 'microphone' }); + if (status.state === 'denied') { + speechSupport.textContent = 'Mikrofon blockiert. Bitte im Browser freigeben.'; + } else if (status.state === 'granted') { + speechSupport.textContent = 'Klicke zum Aufnehmen, nochmal klicken zum Stoppen.'; + } + status.onchange = () => updateMicPermissionStatus(); + } catch (err) { + // Ignore permission query errors and keep default text + } +} + function initSpeechRecognition() { if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { speechSupport.textContent = 'Mikrofon-Zugriff wird von diesem Browser nicht unterstützt.'; @@ -471,8 +496,12 @@ function initSpeechRecognition() { speechBtn.disabled = true; return; } + if (!window.isSecureContext) { + speechSupport.textContent = 'Mikrofon funktioniert nur über HTTPS.'; + } speechSupport.textContent = 'Klicke zum Aufnehmen, nochmal klicken zum Stoppen.'; + updateMicPermissionStatus(); speechBtn.onclick = async () => { if (isRecording) { @@ -526,7 +555,7 @@ async function startRecording() { } catch (error) { console.error('Failed to start recording:', error); - speechStatus.textContent = 'Mikrofon-Zugriff verweigert.'; + speechStatus.textContent = getMicErrorMessage(error); speechStatus.classList.remove('hidden'); setTimeout(() => speechStatus.classList.add('hidden'), 3000); } diff --git a/src/web/templates/user/company_manage_post_detail.html b/src/web/templates/user/company_manage_post_detail.html index d57f78e..a032787 100644 --- a/src/web/templates/user/company_manage_post_detail.html +++ b/src/web/templates/user/company_manage_post_detail.html @@ -365,6 +365,20 @@
{% endif %} + + {% if style_card %} +
+
+

+ + Style Card +

+ Quelle: Profilanalyse +
+
{{ style_card }}
+
+ {% endif %} + {% if not permissions or permissions.can_schedule_posts %}
diff --git a/src/web/templates/user/create_post.html b/src/web/templates/user/create_post.html index db2d142..75d2a37 100644 --- a/src/web/templates/user/create_post.html +++ b/src/web/templates/user/create_post.html @@ -482,6 +482,31 @@ function renderTopicsList(data) { let mediaRecorder = null; let audioChunks = []; +function getMicErrorMessage(error) { + if (!error || !error.name) return 'Mikrofon-Zugriff fehlgeschlagen.'; + if (error.name === 'NotAllowedError') return 'Mikrofon-Zugriff verweigert. Bitte im Browser erlauben.'; + if (error.name === 'NotFoundError') return 'Kein Mikrofon gefunden.'; + if (error.name === 'NotReadableError') return 'Mikrofon wird bereits verwendet.'; + if (error.name === 'SecurityError') return 'Mikrofon funktioniert nur über HTTPS.'; + if (error.name === 'NotSupportedError') return 'Mikrofon wird in diesem Browser nicht unterstützt.'; + return `Mikrofon-Fehler: ${error.name}`; +} + +async function updateMicPermissionStatus() { + if (!navigator.permissions || !navigator.permissions.query) return; + try { + const status = await navigator.permissions.query({ name: 'microphone' }); + if (status.state === 'denied') { + speechSupport.textContent = 'Mikrofon blockiert. Bitte im Browser freigeben.'; + } else if (status.state === 'granted') { + speechSupport.textContent = 'Klicke zum Aufnehmen, nochmal klicken zum Stoppen.'; + } + status.onchange = () => updateMicPermissionStatus(); + } catch (err) { + // Ignore permission query errors and keep default text + } +} + function initSpeechRecognition() { if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { speechSupport.textContent = 'Mikrofon-Zugriff wird von diesem Browser nicht unterstützt.'; @@ -489,8 +514,12 @@ function initSpeechRecognition() { speechBtn.disabled = true; return; } + if (!window.isSecureContext) { + speechSupport.textContent = 'Mikrofon funktioniert nur über HTTPS.'; + } speechSupport.textContent = 'Klicke zum Aufnehmen, nochmal klicken zum Stoppen.'; + updateMicPermissionStatus(); speechBtn.onclick = async () => { if (isRecording) { @@ -551,7 +580,7 @@ async function startRecording() { } catch (error) { console.error('Failed to start recording:', error); - speechStatus.textContent = 'Mikrofon-Zugriff verweigert.'; + speechStatus.textContent = getMicErrorMessage(error); speechStatus.classList.remove('hidden'); setTimeout(() => speechStatus.classList.add('hidden'), 3000); } diff --git a/src/web/templates/user/create_post_file.html b/src/web/templates/user/create_post_file.html index b74b7e5..e9683e2 100644 --- a/src/web/templates/user/create_post_file.html +++ b/src/web/templates/user/create_post_file.html @@ -502,6 +502,31 @@ fileInput.addEventListener('change', () => { let mediaRecorder = null; let audioChunks = []; +function getMicErrorMessage(error) { + if (!error || !error.name) return 'Mikrofon-Zugriff fehlgeschlagen.'; + if (error.name === 'NotAllowedError') return 'Mikrofon-Zugriff verweigert. Bitte im Browser erlauben.'; + if (error.name === 'NotFoundError') return 'Kein Mikrofon gefunden.'; + if (error.name === 'NotReadableError') return 'Mikrofon wird bereits verwendet.'; + if (error.name === 'SecurityError') return 'Mikrofon funktioniert nur über HTTPS.'; + if (error.name === 'NotSupportedError') return 'Mikrofon wird in diesem Browser nicht unterstützt.'; + return `Mikrofon-Fehler: ${error.name}`; +} + +async function updateMicPermissionStatus() { + if (!navigator.permissions || !navigator.permissions.query) return; + try { + const status = await navigator.permissions.query({ name: 'microphone' }); + if (status.state === 'denied') { + speechSupport.textContent = 'Mikrofon blockiert. Bitte im Browser freigeben.'; + } else if (status.state === 'granted') { + speechSupport.textContent = 'Klicke zum Aufnehmen, nochmal klicken zum Stoppen.'; + } + status.onchange = () => updateMicPermissionStatus(); + } catch (err) { + // Ignore permission query errors and keep default text + } +} + function initSpeechRecognition() { if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { speechSupport.textContent = 'Mikrofon-Zugriff wird von diesem Browser nicht unterstützt.'; @@ -509,8 +534,12 @@ function initSpeechRecognition() { speechBtn.disabled = true; return; } + if (!window.isSecureContext) { + speechSupport.textContent = 'Mikrofon funktioniert nur über HTTPS.'; + } speechSupport.textContent = 'Klicke zum Aufnehmen, nochmal klicken zum Stoppen.'; + updateMicPermissionStatus(); speechBtn.onclick = async () => { if (isRecording) { @@ -571,7 +600,7 @@ async function startRecording() { } catch (error) { console.error('Failed to start recording:', error); - speechStatus.textContent = 'Mikrofon-Zugriff verweigert.'; + speechStatus.textContent = getMicErrorMessage(error); speechStatus.classList.remove('hidden'); setTimeout(() => speechStatus.classList.add('hidden'), 3000); } diff --git a/src/web/templates/user/create_post_link.html b/src/web/templates/user/create_post_link.html index 831bc98..46c5280 100644 --- a/src/web/templates/user/create_post_link.html +++ b/src/web/templates/user/create_post_link.html @@ -513,6 +513,31 @@ async function useManualTranscript() { let mediaRecorder = null; let audioChunks = []; +function getMicErrorMessage(error) { + if (!error || !error.name) return 'Mikrofon-Zugriff fehlgeschlagen.'; + if (error.name === 'NotAllowedError') return 'Mikrofon-Zugriff verweigert. Bitte im Browser erlauben.'; + if (error.name === 'NotFoundError') return 'Kein Mikrofon gefunden.'; + if (error.name === 'NotReadableError') return 'Mikrofon wird bereits verwendet.'; + if (error.name === 'SecurityError') return 'Mikrofon funktioniert nur über HTTPS.'; + if (error.name === 'NotSupportedError') return 'Mikrofon wird in diesem Browser nicht unterstützt.'; + return `Mikrofon-Fehler: ${error.name}`; +} + +async function updateMicPermissionStatus() { + if (!navigator.permissions || !navigator.permissions.query) return; + try { + const status = await navigator.permissions.query({ name: 'microphone' }); + if (status.state === 'denied') { + speechSupport.textContent = 'Mikrofon blockiert. Bitte im Browser freigeben.'; + } else if (status.state === 'granted') { + speechSupport.textContent = 'Klicke zum Aufnehmen, nochmal klicken zum Stoppen.'; + } + status.onchange = () => updateMicPermissionStatus(); + } catch (err) { + // Ignore permission query errors and keep default text + } +} + function initSpeechRecognition() { if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { speechSupport.textContent = 'Mikrofon-Zugriff wird von diesem Browser nicht unterstützt.'; @@ -520,8 +545,12 @@ function initSpeechRecognition() { speechBtn.disabled = true; return; } + if (!window.isSecureContext) { + speechSupport.textContent = 'Mikrofon funktioniert nur über HTTPS.'; + } speechSupport.textContent = 'Klicke zum Aufnehmen, nochmal klicken zum Stoppen.'; + updateMicPermissionStatus(); speechBtn.onclick = async () => { if (isRecording) { @@ -582,7 +611,7 @@ async function startRecording() { } catch (error) { console.error('Failed to start recording:', error); - speechStatus.textContent = 'Mikrofon-Zugriff verweigert.'; + speechStatus.textContent = getMicErrorMessage(error); speechStatus.classList.remove('hidden'); setTimeout(() => speechStatus.classList.add('hidden'), 3000); } diff --git a/src/web/templates/user/post_detail.html b/src/web/templates/user/post_detail.html index 898b946..d225c8f 100644 --- a/src/web/templates/user/post_detail.html +++ b/src/web/templates/user/post_detail.html @@ -778,6 +778,20 @@
+ + {% if style_card %} +
+
+

+ + Style Card +

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

@@ -832,45 +846,18 @@
- - {% if final_feedback and final_feedback.get('scores') and final_feedback.get('overall_score') is not none %} + + {% if final_feedback and final_feedback.get('overall_score') is not none %}
-

+

- Score: {{ final_feedback.get('overall_score', 0) }}/100 + Gesamt-Score

-
-
-
- Authentizität - {{ final_feedback.scores.authenticity_and_style }}/40 -
-
-
-
-
-
-
- Content - {{ final_feedback.scores.content_quality }}/35 -
-
-
-
-
-
-
- Technik - {{ final_feedback.scores.technical_execution }}/25 -
-
-
-
-
-
+
{{ final_feedback.get('overall_score', 0) }}/100
{% endif %} + {% if final_feedback %}
diff --git a/src/web/user/routes.py b/src/web/user/routes.py index 09c4a7a..0d7fd85 100644 --- a/src/web/user/routes.py +++ b/src/web/user/routes.py @@ -1723,6 +1723,10 @@ async def post_detail_page(request: Request, post_id: str): ) reference_posts = [p.post_text for p in linkedin_posts if p.post_text and len(p.post_text) > 100][:10] profile_analysis = profile_analysis_record.full_analysis if profile_analysis_record else None + style_card = None + if profile_analysis and isinstance(profile_analysis, dict): + from src.agents.writer import WriterAgent + style_card = WriterAgent()._build_style_card(profile_analysis) post_type = None post_type_analysis = None @@ -1762,6 +1766,7 @@ async def post_detail_page(request: Request, post_id: str): "post_type": post_type, "post_type_analysis": post_type_analysis, "final_feedback": final_feedback, + "style_card": style_card, "profile_picture_url": profile_picture_url, "profile_picture": profile_picture_url, "media_items_dict": media_items_dict, @@ -3429,10 +3434,11 @@ async def company_manage_post_detail(request: Request, post_id: str, employee_id return RedirectResponse(url="/company/manage", status_code=302) # Get employee info + post in parallel - emp_profile, emp_user, post = await asyncio.gather( + emp_profile, emp_user, post, profile_analysis_record = await asyncio.gather( db.get_profile(UUID(employee_id)), db.get_user(UUID(employee_id)), db.get_generated_post(UUID(post_id)), + db.get_profile_analysis(UUID(employee_id)), ) if not emp_profile: return RedirectResponse(url="/company/manage", status_code=302) @@ -3455,6 +3461,11 @@ async def company_manage_post_detail(request: Request, post_id: str, employee_id display_name=emp_profile.display_name ) profile_picture_url = await get_user_avatar(emp_session, emp_profile.id) + profile_analysis = profile_analysis_record.full_analysis if profile_analysis_record else None + style_card = None + if profile_analysis and isinstance(profile_analysis, dict): + from src.agents.writer import WriterAgent + style_card = WriterAgent()._build_style_card(profile_analysis) # Convert media_items to dicts for JSON serialization in template media_items_dict = [] @@ -3484,6 +3495,7 @@ async def company_manage_post_detail(request: Request, post_id: str, employee_id "post": post, "media_items_dict": media_items_dict, "profile_picture_url": profile_picture_url, + "style_card": style_card, "permissions": permissions, "current_employee_id": employee_id, "limit_reached": limit_reached, @@ -4373,15 +4385,22 @@ async def chat_generate_post(request: Request): "relevance": "User-specified content" } + strategy_weight = orchestrator._get_effective_strategy_weight( + base_weight=post_type.strategy_weight, + post_type=post_type, + post_type_analysis=post_type.analysis if post_type else None + ) + plan = await orchestrator.generate_post_plan( topic=topic, profile_analysis=profile_analysis.full_analysis, company_strategy=company_strategy, - strategy_weight=getattr(post_type, 'strategy_weight', 0.5), - user_thoughts=message + strategy_weight=strategy_weight, + user_thoughts=message, + post_type_analysis=post_type.analysis if post_type and post_type.analysis else None ) if plan: - topic["post_plan"] = plan + topic["content_plan"] = plan # Generate post post_content = await writer.process( @@ -4391,10 +4410,64 @@ async def chat_generate_post(request: Request): post_type=post_type, user_thoughts=message, # CRITICAL: User's input as primary content company_strategy=company_strategy, - strategy_weight=post_type.strategy_weight + strategy_weight=strategy_weight ) post_content = sanitize_post_content(post_content) + # Run critic + one revision pass for chat flow quality parity + critic_result = await orchestrator.critic.process( + post=post_content, + profile_analysis=profile_analysis.full_analysis, + topic=topic, + example_posts=example_post_texts, + iteration=1, + max_iterations=2 + ) + critic_result = orchestrator._normalize_critic_scores(critic_result) + if not critic_result.get("approved", False): + post_content = await writer.process( + topic=topic, + profile_analysis=profile_analysis.full_analysis, + feedback=critic_result.get("feedback", ""), + previous_version=post_content, + example_posts=example_post_texts, + critic_result=critic_result, + post_type=post_type, + user_thoughts=message, + company_strategy=company_strategy, + strategy_weight=strategy_weight + ) + post_content = sanitize_post_content(post_content) + critic_result = await orchestrator.critic.process( + post=post_content, + profile_analysis=profile_analysis.full_analysis, + topic=topic, + example_posts=example_post_texts, + iteration=2, + max_iterations=2 + ) + critic_result = orchestrator._normalize_critic_scores(critic_result) + + # Quality checks + final polish (same as wizard) + if settings.quality_refiner_enabled: + quality_checks = await orchestrator._run_quality_checks(post_content, example_post_texts) + grammar_errors = quality_checks['grammar_check'].get('error_count', 0) + style_similarity = quality_checks['style_check'].get('avg_similarity', 1.0) + readability_passed = quality_checks['readability_check'].get('passed', True) + needs_polish = ( + grammar_errors > 0 or + style_similarity < 0.75 or + not readability_passed + ) + if needs_polish: + post_content = await orchestrator.quality_refiner.final_polish( + post=post_content, + quality_checks=quality_checks, + profile_analysis=profile_analysis.full_analysis, + example_posts=example_post_texts + ) + post_content = sanitize_post_content(post_content) + # Generate conversation ID import uuid conversation_id = str(uuid.uuid4()) @@ -4495,15 +4568,22 @@ async def chat_refine_post(request: Request): "relevance": "User refinement request" } + strategy_weight = orchestrator._get_effective_strategy_weight( + base_weight=getattr(post_type, 'strategy_weight', 0.5), + post_type=post_type, + post_type_analysis=post_type.analysis if post_type else None + ) + plan = await orchestrator.generate_post_plan( topic=topic, profile_analysis=full_analysis, company_strategy=company_strategy, - strategy_weight=getattr(post_type, 'strategy_weight', 0.5), - user_thoughts=message + strategy_weight=strategy_weight, + user_thoughts=message, + post_type_analysis=post_type.analysis if post_type and post_type.analysis else None ) if plan: - topic["post_plan"] = plan + topic["content_plan"] = plan # Use writer's revision capability refined_post = await writer.process( @@ -4515,10 +4595,64 @@ async def chat_refine_post(request: Request): post_type=post_type, user_thoughts=message, company_strategy=company_strategy, - strategy_weight=getattr(post_type, 'strategy_weight', 0.5) + strategy_weight=strategy_weight ) refined_post = sanitize_post_content(refined_post) + # Critic + quality checks for chat refine parity + critic_result = await orchestrator.critic.process( + post=refined_post, + profile_analysis=full_analysis, + topic=topic, + example_posts=example_post_texts, + iteration=1, + max_iterations=2 + ) + critic_result = orchestrator._normalize_critic_scores(critic_result) + if not critic_result.get("approved", False): + refined_post = await writer.process( + topic=topic, + profile_analysis=full_analysis, + feedback=critic_result.get("feedback", ""), + previous_version=refined_post, + example_posts=example_post_texts, + critic_result=critic_result, + post_type=post_type, + user_thoughts=message, + company_strategy=company_strategy, + strategy_weight=strategy_weight + ) + refined_post = sanitize_post_content(refined_post) + + critic_result = await orchestrator.critic.process( + post=refined_post, + profile_analysis=full_analysis, + topic=topic, + example_posts=example_post_texts, + iteration=2, + max_iterations=2 + ) + critic_result = orchestrator._normalize_critic_scores(critic_result) + + if settings.quality_refiner_enabled: + quality_checks = await orchestrator._run_quality_checks(refined_post, example_post_texts) + grammar_errors = quality_checks['grammar_check'].get('error_count', 0) + style_similarity = quality_checks['style_check'].get('avg_similarity', 1.0) + readability_passed = quality_checks['readability_check'].get('passed', True) + needs_polish = ( + grammar_errors > 0 or + style_similarity < 0.75 or + not readability_passed + ) + if needs_polish: + refined_post = await orchestrator.quality_refiner.final_polish( + post=refined_post, + quality_checks=quality_checks, + profile_analysis=full_analysis, + example_posts=example_post_texts + ) + refined_post = sanitize_post_content(refined_post) + return JSONResponse({ "success": True, "post": refined_post, @@ -4815,15 +4949,22 @@ async def company_chat_generate_post(request: Request): topic = {"title": message[:100], "fact": message, "relevance": "Company-created content"} + strategy_weight = orchestrator._get_effective_strategy_weight( + base_weight=post_type.strategy_weight, + post_type=post_type, + post_type_analysis=post_type.analysis if post_type else None + ) + plan = await orchestrator.generate_post_plan( topic=topic, profile_analysis=profile_analysis.full_analysis, company_strategy=company_strategy, - strategy_weight=getattr(post_type, 'strategy_weight', 0.5), - user_thoughts=message + strategy_weight=strategy_weight, + user_thoughts=message, + post_type_analysis=post_type.analysis if post_type and post_type.analysis else None ) if plan: - topic["post_plan"] = plan + topic["content_plan"] = plan post_content = await writer.process( topic=topic, profile_analysis=profile_analysis.full_analysis, @@ -4831,10 +4972,53 @@ async def company_chat_generate_post(request: Request): post_type=post_type, user_thoughts=message, company_strategy=company_strategy, - strategy_weight=post_type.strategy_weight + strategy_weight=strategy_weight ) post_content = sanitize_post_content(post_content) + # Run critic + one revision pass for chat flow quality parity + critic_result = await orchestrator.critic.process( + post=post_content, + profile_analysis=profile_analysis.full_analysis, + topic=topic, + example_posts=example_post_texts, + iteration=1, + max_iterations=2 + ) + if not critic_result.get("approved", False): + post_content = await writer.process( + topic=topic, + profile_analysis=profile_analysis.full_analysis, + feedback=critic_result.get("feedback", ""), + previous_version=post_content, + example_posts=example_post_texts, + critic_result=critic_result, + post_type=post_type, + user_thoughts=message, + company_strategy=company_strategy, + strategy_weight=strategy_weight + ) + post_content = sanitize_post_content(post_content) + + if settings.quality_refiner_enabled: + quality_checks = await orchestrator._run_quality_checks(post_content, example_post_texts) + grammar_errors = quality_checks['grammar_check'].get('error_count', 0) + style_similarity = quality_checks['style_check'].get('avg_similarity', 1.0) + readability_passed = quality_checks['readability_check'].get('passed', True) + needs_polish = ( + grammar_errors > 0 or + style_similarity < 0.75 or + not readability_passed + ) + if needs_polish: + post_content = await orchestrator.quality_refiner.final_polish( + post=post_content, + quality_checks=quality_checks, + profile_analysis=profile_analysis.full_analysis, + example_posts=example_post_texts + ) + post_content = sanitize_post_content(post_content) + return JSONResponse({ "success": True, "post": post_content,