tried to further improve writing quality

This commit is contained in:
2026-03-17 15:28:01 +01:00
parent d4bf3fe25d
commit b3bb67f3ad
10 changed files with 750 additions and 123 deletions

View File

@@ -263,7 +263,12 @@ class WriterAgent(BaseAgent):
num_drafts = min(max(settings.writer_multi_draft_count, 2), 5) # Clamp between 2-5 num_drafts = min(max(settings.writer_multi_draft_count, 2), 5) # Clamp between 2-5
logger.info(f"Generating {num_drafts} drafts for selection") 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 # Generate drafts in parallel with different temperatures/approaches
draft_configs = [ 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" why_section = f"\n**WARUM DU DARÜBER SCHREIBEN SOLLTEST:**\n{topic.get('why_this_person')}\n"
plan_section = "" plan_section = ""
if topic.get("post_plan"): if topic.get("content_plan"):
plan_section = f"\n**POST-PLAN (VERBINDLICH):**\n{json.dumps(topic.get('post_plan'), ensure_ascii=False, indent=2)}\n" 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 # User thoughts section
thoughts_section = "" thoughts_section = ""
@@ -416,6 +426,16 @@ Schreibe einen authentischen LinkedIn-Post, der:
4. Die Key Facts einbaut wo es passt 4. Die Key Facts einbaut wo es passt
5. Mit einem passenden CTA endet""" 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: return f"""Schreibe einen LinkedIn-Post zu folgendem Thema:
**THEMA:** {topic.get('title', 'Unbekanntes Thema')} **THEMA:** {topic.get('title', 'Unbekanntes Thema')}
@@ -437,6 +457,7 @@ WICHTIG:
- Vermeide KI-typische Formulierungen ("In der heutigen Zeit", "Tauchen Sie ein", etc.) - Vermeide KI-typische Formulierungen ("In der heutigen Zeit", "Tauchen Sie ein", etc.)
- Schreibe natürlich und menschlich - Schreibe natürlich und menschlich
- Der Post soll SOFORT 85+ Punkte im Review erreichen - Der Post soll SOFORT 85+ Punkte im Review erreichen
{content_plan_note}
Gib NUR den fertigen Post zurück.""" 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: elif len(selected_examples) > 3:
selected_examples = random.sample(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) user_prompt = self._get_user_prompt(topic, feedback, previous_version, critic_result, user_thoughts, selected_hook)
# OPTIMIERT: Niedrigere Temperature (0.5 statt 0.6) für konsistenteren Stil # OPTIMIERT: Niedrigere Temperature (0.5 statt 0.6) für konsistenteren Stil
@@ -570,6 +596,104 @@ Analysiere jeden Entwurf kurz und wähle den besten. Antworte im JSON-Format:
logger.info("Post written successfully") logger.info("Post written successfully")
return post.strip() 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( def _get_system_prompt(
self, self,
profile_analysis: Dict[str, Any], 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 "" score_text = f"**AKTUELLER SCORE:** {critic_result.get('overall_score', 'N/A')}/100\n\n" if critic_result else ""
plan_section = "" plan_section = ""
if topic.get("post_plan"): if topic.get("content_plan"):
plan_section = f"\n**POST-PLAN (VERBINDLICH):**\n{json.dumps(topic.get('post_plan'), ensure_ascii=False, indent=2)}\n" 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. 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" source_section = f"\n**QUELLE:** {source_title} {source_url}\n"
plan_section = "" plan_section = ""
if topic.get("post_plan"): if topic.get("content_plan"):
plan_section = f"\n**POST-PLAN (VERBINDLICH):**\n{json.dumps(topic.get('post_plan'), ensure_ascii=False, indent=2)}\n" plan_section = f"\n**CONTENT-PLAN (VERBINDLICH):**\n{json.dumps(topic.get('content_plan'), ensure_ascii=False, indent=2)}\n"
# User thoughts section # User thoughts section
thoughts_section = "" thoughts_section = ""
@@ -1090,6 +1219,16 @@ Schreibe einen authentischen LinkedIn-Post, der:
4. Die Key Facts verwendet wo es passt 4. Die Key Facts verwendet wo es passt
5. Mit einem passenden CTA endet""" 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: return f"""Schreibe einen LinkedIn-Post zu folgendem Thema:
**THEMA:** {topic.get('title', 'Unbekanntes Thema')} **THEMA:** {topic.get('title', 'Unbekanntes Thema')}
@@ -1108,6 +1247,7 @@ WICHTIG:
- Vermeide KI-typische Formulierungen ("In der heutigen Zeit", "Tauchen Sie ein", etc.) - Vermeide KI-typische Formulierungen ("In der heutigen Zeit", "Tauchen Sie ein", etc.)
- Schreibe natürlich und menschlich - Schreibe natürlich und menschlich
- Der Post soll SOFORT 85+ Punkte im Review erreichen - Der Post soll SOFORT 85+ Punkte im Review erreichen
{content_plan_note}
Gib NUR den fertigen Post zurück.""" Gib NUR den fertigen Post zurück."""

View File

@@ -48,6 +48,51 @@ class WorkflowOrchestrator:
] ]
logger.info("WorkflowOrchestrator initialized with quality check & refiner agents") 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, def _set_tracking(self, operation: str, user_id: Optional[str] = None,
company_id: Optional[str] = None): company_id: Optional[str] = None):
"""Set tracking context on all agents.""" """Set tracking context on all agents."""
@@ -56,6 +101,20 @@ class WorkflowOrchestrator:
for agent in self._all_agents: for agent in self._all_agents:
agent.set_tracking_context(operation=operation, user_id=uid, company_id=comp_id) 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: async def _resolve_tracking_ids(self, user_id: UUID) -> dict:
"""Resolve company_id from a user_id for tracking.""" """Resolve company_id from a user_id for tracking."""
try: try:
@@ -600,8 +659,13 @@ class WorkflowOrchestrator:
if post_type: if post_type:
if post_type.analysis: if post_type.analysis:
post_type_analysis = post_type.analysis post_type_analysis = post_type.analysis
strategy_weight = post_type.strategy_weight # Extract strategy weight from post type strategy_weight = post_type.strategy_weight # Base strategy weight from post type
logger.info(f"Using post type '{post_type.name}' with strategy weight {strategy_weight:.1f}") 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 # Load user's real posts as style examples
# If post_type_id is specified, only use posts of that type # If post_type_id is specified, only use posts of that type
@@ -639,8 +703,8 @@ class WorkflowOrchestrator:
company_strategy = company.company_strategy company_strategy = company.company_strategy
logger.info(f"Loaded company strategy for post creation: {company.name}") logger.info(f"Loaded company strategy for post creation: {company.name}")
# Build a structured post plan (used to guide writing) # Build a structured content plan (content only, used to guide writing)
post_plan = await self._build_post_plan( content_plan = await self._build_content_plan(
topic=topic, topic=topic,
profile_analysis=profile_analysis.full_analysis, profile_analysis=profile_analysis.full_analysis,
company_strategy=company_strategy, company_strategy=company_strategy,
@@ -649,8 +713,12 @@ class WorkflowOrchestrator:
user_thoughts=user_thoughts, user_thoughts=user_thoughts,
post_type_analysis=post_type_analysis post_type_analysis=post_type_analysis
) )
if post_plan: if content_plan:
topic = {**topic, "post_plan": post_plan} topic = {
**topic,
"content_plan": content_plan,
"content_quality": content_plan.get("quality_tier")
}
# Writer-Critic loop # Writer-Critic loop
while iteration < max_iterations and not approved: while iteration < max_iterations and not approved:
@@ -710,6 +778,7 @@ class WorkflowOrchestrator:
iteration=iteration, iteration=iteration,
max_iterations=max_iterations max_iterations=max_iterations
) )
critic_result = self._normalize_critic_scores(critic_result)
# Style/Strategy scoring (enforce thresholds based on strategy_weight) # Style/Strategy scoring (enforce thresholds based on strategy_weight)
style_strategy = await self._score_style_strategy( style_strategy = await self._score_style_strategy(
@@ -818,6 +887,10 @@ class WorkflowOrchestrator:
# Save generated post # Save generated post
from src.database.models import GeneratedPost from src.database.models import GeneratedPost
metadata = {
"content_plan": content_plan,
"strategy_weight_used": strategy_weight
}
generated_post = GeneratedPost( generated_post = GeneratedPost(
user_id=user_id, user_id=user_id,
topic_title=topic.get("title", "Unknown"), topic_title=topic.get("title", "Unknown"),
@@ -826,7 +899,8 @@ class WorkflowOrchestrator:
writer_versions=writer_versions, writer_versions=writer_versions,
critic_feedback=critic_feedback_list, critic_feedback=critic_feedback_list,
status=status, 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) saved_post = await db.save_generated_post(generated_post)
@@ -884,7 +958,102 @@ class WorkflowOrchestrator:
"readability_check": readability_result "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, self,
topic: Dict[str, Any], topic: Dict[str, Any],
profile_analysis: Dict[str, Any], profile_analysis: Dict[str, Any],
@@ -894,28 +1063,44 @@ class WorkflowOrchestrator:
user_thoughts: str = "", user_thoughts: str = "",
post_type_analysis: Optional[Dict[str, Any]] = None post_type_analysis: Optional[Dict[str, Any]] = None
) -> Optional[Dict[str, Any]]: ) -> Optional[Dict[str, Any]]:
"""Create a structured plan to guide the writer.""" """Create a structured content plan (content only, no style)."""
try: 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 = ( system_prompt = (
"Du erstellst einen kompakten Schreibplan für einen LinkedIn-Post. " "Du erstellst einen kompakten CONTENT-PLAN für einen LinkedIn-Post. "
"Der Plan muss Stil der Person UND Unternehmensstrategie berücksichtigen. " "Fokus NUR auf Inhalt, Logik, Fakten und Struktur. "
"Antwort nur als JSON." "KEINE Stil-Anweisungen. Antwort nur als JSON."
) )
user_prompt = f""" user_prompt = f"""
Thema: {topic.get('title')} Thema: {topic.get('title')}
Kern-Fakt: {topic.get('fact') or topic.get('summary','')} Kern-Fakt: {topic.get('fact') or topic.get('summary','')}
Relevanz: {topic.get('relevance')} 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"} Ausgewählter Hook: {selected_hook or "keiner"}
Persönliche Gedanken: {user_thoughts or "keine"} 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): Unternehmensstrategie (Kurz):
Mission: {company_strategy.get('mission') if company_strategy else ''} Mission: {company_strategy.get('mission') if company_strategy else ''}
Brand Voice: {company_strategy.get('brand_voice') 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 []} Don't: {company_strategy.get('donts') if company_strategy else []}
Strategy Weight: {strategy_weight:.1f} 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: Gib JSON im Format:
{{ {{
"hook_type": "Story/Fakten/These/Frage/...", "quality_tier": "basic/standard/premium",
"structure": ["Hook", "Body", "CTA"], "required_elements": ["..."],
"style_requirements": ["2-4 konkrete Stil-Elemente/Signaturphrasen"], "hook_idea": "1-2 Sätze (Inhaltlich, nicht stilistisch)",
"strategy_requirements": ["2-4 Strategie-Elemente/Content-Pillar/Do"], "thesis": "Kernaussage in einem Satz",
"cta_type": "Frage/Aufforderung/Diskussion/..." "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( raw = await self.writer.call_openai(
@@ -941,7 +1134,21 @@ Gib JSON im Format:
temperature=0.2, temperature=0.2,
response_format={"type": "json_object"} 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: except Exception as e:
logger.warning(f"Post plan generation failed: {e}") logger.warning(f"Post plan generation failed: {e}")
return None return None
@@ -1005,8 +1212,8 @@ Gib JSON im Format:
user_thoughts: str = "", user_thoughts: str = "",
post_type_analysis: Optional[Dict[str, Any]] = None post_type_analysis: Optional[Dict[str, Any]] = None
) -> Optional[Dict[str, Any]]: ) -> Optional[Dict[str, Any]]:
"""Public wrapper to build a post plan.""" """Public wrapper to build a content plan."""
return await self._build_post_plan( return await self._build_content_plan(
topic=topic, topic=topic,
profile_analysis=profile_analysis, profile_analysis=profile_analysis,
company_strategy=company_strategy, company_strategy=company_strategy,

View File

@@ -422,48 +422,14 @@
<!-- Sidebar --> <!-- Sidebar -->
<div class="space-y-6"> <div class="space-y-6">
<!-- Score Breakdown --> <!-- Overall Score -->
{% if final_feedback and final_feedback.scores %} {% if final_feedback and final_feedback.get('overall_score') is not none %}
<div class="section-card rounded-xl p-6"> <div class="section-card rounded-xl p-6">
<h3 class="font-semibold text-white mb-4 flex items-center gap-2"> <h3 class="font-semibold text-white mb-2 flex items-center gap-2">
<svg class="w-5 h-5 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/></svg> <svg class="w-5 h-5 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/></svg>
Score-Aufschlüsselung Gesamt-Score
</h3> </h3>
<div class="space-y-4"> <div class="text-3xl font-bold text-brand-highlight">{{ final_feedback.overall_score }}/100</div>
<div>
<div class="flex justify-between text-sm mb-2">
<span class="text-gray-400">Authentizität & Stil</span>
<span class="text-white font-medium">{{ final_feedback.scores.authenticity_and_style }}/40</span>
</div>
<div class="stat-bar">
<div class="stat-bar-fill" style="width: {{ (final_feedback.scores.authenticity_and_style / 40 * 100) | int }}%"></div>
</div>
</div>
<div>
<div class="flex justify-between text-sm mb-2">
<span class="text-gray-400">Content-Qualität</span>
<span class="text-white font-medium">{{ final_feedback.scores.content_quality }}/35</span>
</div>
<div class="stat-bar">
<div class="stat-bar-fill" style="width: {{ (final_feedback.scores.content_quality / 35 * 100) | int }}%"></div>
</div>
</div>
<div>
<div class="flex justify-between text-sm mb-2">
<span class="text-gray-400">Technische Umsetzung</span>
<span class="text-white font-medium">{{ final_feedback.scores.technical_execution }}/25</span>
</div>
<div class="stat-bar">
<div class="stat-bar-fill" style="width: {{ (final_feedback.scores.technical_execution / 25 * 100) | int }}%"></div>
</div>
</div>
<div class="pt-3 border-t border-brand-bg-light">
<div class="flex justify-between">
<span class="text-gray-300 font-medium">Gesamt</span>
<span class="text-brand-highlight font-bold text-lg">{{ final_feedback.overall_score }}/100</span>
</div>
</div>
</div>
</div> </div>
{% endif %} {% endif %}
@@ -488,6 +454,19 @@
</div> </div>
{% endif %} {% endif %}
{% if style_card %}
<div class="section-card rounded-xl p-6">
<div class="flex items-center justify-between mb-3">
<h3 class="font-semibold text-white flex items-center gap-2">
<svg class="w-5 h-5 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 8h10M7 12h10M7 16h6"/></svg>
Style Card
</h3>
<span class="text-xs text-gray-500">Quelle: Profilanalyse</span>
</div>
<pre class="whitespace-pre-wrap text-gray-300 text-xs bg-brand-bg/50 rounded-lg p-4 border border-brand-bg-light">{{ style_card }}</pre>
</div>
{% endif %}
<!-- Quick Actions --> <!-- Quick Actions -->
<div class="section-card rounded-xl p-6"> <div class="section-card rounded-xl p-6">
<h3 class="font-semibold text-white mb-4">Aktionen</h3> <h3 class="font-semibold text-white mb-4">Aktionen</h3>

View File

@@ -464,6 +464,31 @@ function renderTopicsList(data) {
let mediaRecorder = null; let mediaRecorder = null;
let audioChunks = []; 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() { function initSpeechRecognition() {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
speechSupport.textContent = 'Mikrofon-Zugriff wird von diesem Browser nicht unterstützt.'; speechSupport.textContent = 'Mikrofon-Zugriff wird von diesem Browser nicht unterstützt.';
@@ -471,8 +496,12 @@ function initSpeechRecognition() {
speechBtn.disabled = true; speechBtn.disabled = true;
return; return;
} }
if (!window.isSecureContext) {
speechSupport.textContent = 'Mikrofon funktioniert nur über HTTPS.';
}
speechSupport.textContent = 'Klicke zum Aufnehmen, nochmal klicken zum Stoppen.'; speechSupport.textContent = 'Klicke zum Aufnehmen, nochmal klicken zum Stoppen.';
updateMicPermissionStatus();
speechBtn.onclick = async () => { speechBtn.onclick = async () => {
if (isRecording) { if (isRecording) {
@@ -526,7 +555,7 @@ async function startRecording() {
} catch (error) { } catch (error) {
console.error('Failed to start recording:', error); console.error('Failed to start recording:', error);
speechStatus.textContent = 'Mikrofon-Zugriff verweigert.'; speechStatus.textContent = getMicErrorMessage(error);
speechStatus.classList.remove('hidden'); speechStatus.classList.remove('hidden');
setTimeout(() => speechStatus.classList.add('hidden'), 3000); setTimeout(() => speechStatus.classList.add('hidden'), 3000);
} }

View File

@@ -365,6 +365,20 @@
</div> </div>
{% endif %} {% endif %}
<!-- Style Card -->
{% if style_card %}
<div class="section-card rounded-xl p-6">
<div class="flex items-center justify-between mb-3">
<h3 class="font-semibold text-white flex items-center gap-2">
<svg class="w-5 h-5 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 8h10M7 12h10M7 16h6"/></svg>
Style Card
</h3>
<span class="text-xs text-gray-500">Quelle: Profilanalyse</span>
</div>
<pre class="whitespace-pre-wrap text-gray-300 text-xs bg-brand-bg/50 rounded-lg p-4 border border-brand-bg-light">{{ style_card }}</pre>
</div>
{% endif %}
<!-- Schedule Section --> <!-- Schedule Section -->
{% if not permissions or permissions.can_schedule_posts %} {% if not permissions or permissions.can_schedule_posts %}
<div class="section-card rounded-xl p-6"> <div class="section-card rounded-xl p-6">

View File

@@ -482,6 +482,31 @@ function renderTopicsList(data) {
let mediaRecorder = null; let mediaRecorder = null;
let audioChunks = []; 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() { function initSpeechRecognition() {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
speechSupport.textContent = 'Mikrofon-Zugriff wird von diesem Browser nicht unterstützt.'; speechSupport.textContent = 'Mikrofon-Zugriff wird von diesem Browser nicht unterstützt.';
@@ -489,8 +514,12 @@ function initSpeechRecognition() {
speechBtn.disabled = true; speechBtn.disabled = true;
return; return;
} }
if (!window.isSecureContext) {
speechSupport.textContent = 'Mikrofon funktioniert nur über HTTPS.';
}
speechSupport.textContent = 'Klicke zum Aufnehmen, nochmal klicken zum Stoppen.'; speechSupport.textContent = 'Klicke zum Aufnehmen, nochmal klicken zum Stoppen.';
updateMicPermissionStatus();
speechBtn.onclick = async () => { speechBtn.onclick = async () => {
if (isRecording) { if (isRecording) {
@@ -551,7 +580,7 @@ async function startRecording() {
} catch (error) { } catch (error) {
console.error('Failed to start recording:', error); console.error('Failed to start recording:', error);
speechStatus.textContent = 'Mikrofon-Zugriff verweigert.'; speechStatus.textContent = getMicErrorMessage(error);
speechStatus.classList.remove('hidden'); speechStatus.classList.remove('hidden');
setTimeout(() => speechStatus.classList.add('hidden'), 3000); setTimeout(() => speechStatus.classList.add('hidden'), 3000);
} }

View File

@@ -502,6 +502,31 @@ fileInput.addEventListener('change', () => {
let mediaRecorder = null; let mediaRecorder = null;
let audioChunks = []; 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() { function initSpeechRecognition() {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
speechSupport.textContent = 'Mikrofon-Zugriff wird von diesem Browser nicht unterstützt.'; speechSupport.textContent = 'Mikrofon-Zugriff wird von diesem Browser nicht unterstützt.';
@@ -509,8 +534,12 @@ function initSpeechRecognition() {
speechBtn.disabled = true; speechBtn.disabled = true;
return; return;
} }
if (!window.isSecureContext) {
speechSupport.textContent = 'Mikrofon funktioniert nur über HTTPS.';
}
speechSupport.textContent = 'Klicke zum Aufnehmen, nochmal klicken zum Stoppen.'; speechSupport.textContent = 'Klicke zum Aufnehmen, nochmal klicken zum Stoppen.';
updateMicPermissionStatus();
speechBtn.onclick = async () => { speechBtn.onclick = async () => {
if (isRecording) { if (isRecording) {
@@ -571,7 +600,7 @@ async function startRecording() {
} catch (error) { } catch (error) {
console.error('Failed to start recording:', error); console.error('Failed to start recording:', error);
speechStatus.textContent = 'Mikrofon-Zugriff verweigert.'; speechStatus.textContent = getMicErrorMessage(error);
speechStatus.classList.remove('hidden'); speechStatus.classList.remove('hidden');
setTimeout(() => speechStatus.classList.add('hidden'), 3000); setTimeout(() => speechStatus.classList.add('hidden'), 3000);
} }

View File

@@ -513,6 +513,31 @@ async function useManualTranscript() {
let mediaRecorder = null; let mediaRecorder = null;
let audioChunks = []; 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() { function initSpeechRecognition() {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
speechSupport.textContent = 'Mikrofon-Zugriff wird von diesem Browser nicht unterstützt.'; speechSupport.textContent = 'Mikrofon-Zugriff wird von diesem Browser nicht unterstützt.';
@@ -520,8 +545,12 @@ function initSpeechRecognition() {
speechBtn.disabled = true; speechBtn.disabled = true;
return; return;
} }
if (!window.isSecureContext) {
speechSupport.textContent = 'Mikrofon funktioniert nur über HTTPS.';
}
speechSupport.textContent = 'Klicke zum Aufnehmen, nochmal klicken zum Stoppen.'; speechSupport.textContent = 'Klicke zum Aufnehmen, nochmal klicken zum Stoppen.';
updateMicPermissionStatus();
speechBtn.onclick = async () => { speechBtn.onclick = async () => {
if (isRecording) { if (isRecording) {
@@ -582,7 +611,7 @@ async function startRecording() {
} catch (error) { } catch (error) {
console.error('Failed to start recording:', error); console.error('Failed to start recording:', error);
speechStatus.textContent = 'Mikrofon-Zugriff verweigert.'; speechStatus.textContent = getMicErrorMessage(error);
speechStatus.classList.remove('hidden'); speechStatus.classList.remove('hidden');
setTimeout(() => speechStatus.classList.add('hidden'), 3000); setTimeout(() => speechStatus.classList.add('hidden'), 3000);
} }

View File

@@ -778,6 +778,20 @@
</div> </div>
</div> </div>
<!-- Style Card -->
{% if style_card %}
<div class="section-card rounded-xl p-6 mb-6">
<div class="flex items-center justify-between mb-3">
<h3 class="font-semibold text-white flex items-center gap-2">
<svg class="w-5 h-5 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 8h10M7 12h10M7 16h6"/></svg>
Style Card
</h3>
<span class="text-xs text-gray-500">Quelle: Profilanalyse</span>
</div>
<pre class="whitespace-pre-wrap text-gray-300 text-xs bg-brand-bg/50 rounded-lg p-4 border border-brand-bg-light">{{ style_card }}</pre>
</div>
{% endif %}
<!-- Media Upload Section (Multi-Media Support) --> <!-- Media Upload Section (Multi-Media Support) -->
<div class="section-card rounded-xl p-6 mb-6"> <div class="section-card rounded-xl p-6 mb-6">
<h3 class="font-semibold text-white mb-4 flex items-center gap-2"> <h3 class="font-semibold text-white mb-4 flex items-center gap-2">
@@ -832,45 +846,18 @@
<!-- Bottom Section: Score, Feedback, Actions --> <!-- Bottom Section: Score, Feedback, Actions -->
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
<!-- Score Breakdown --> <!-- Overall Score -->
{% 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 %}
<div class="section-card rounded-xl p-6"> <div class="section-card rounded-xl p-6">
<h3 class="font-semibold text-white mb-4 flex items-center gap-2"> <h3 class="font-semibold text-white mb-2 flex items-center gap-2">
<svg class="w-5 h-5 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/></svg> <svg class="w-5 h-5 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/></svg>
Score: {{ final_feedback.get('overall_score', 0) }}/100 Gesamt-Score
</h3> </h3>
<div class="space-y-3"> <div class="text-3xl font-bold text-brand-highlight">{{ final_feedback.get('overall_score', 0) }}/100</div>
<div>
<div class="flex justify-between text-sm mb-1">
<span class="text-gray-400">Authentizität</span>
<span class="text-white font-medium">{{ final_feedback.scores.authenticity_and_style }}/40</span>
</div>
<div class="stat-bar">
<div class="stat-bar-fill" style="width: {{ (final_feedback.scores.authenticity_and_style / 40 * 100) | int }}%"></div>
</div>
</div>
<div>
<div class="flex justify-between text-sm mb-1">
<span class="text-gray-400">Content</span>
<span class="text-white font-medium">{{ final_feedback.scores.content_quality }}/35</span>
</div>
<div class="stat-bar">
<div class="stat-bar-fill" style="width: {{ (final_feedback.scores.content_quality / 35 * 100) | int }}%"></div>
</div>
</div>
<div>
<div class="flex justify-between text-sm mb-1">
<span class="text-gray-400">Technik</span>
<span class="text-white font-medium">{{ final_feedback.scores.technical_execution }}/25</span>
</div>
<div class="stat-bar">
<div class="stat-bar-fill" style="width: {{ (final_feedback.scores.technical_execution / 25 * 100) | int }}%"></div>
</div>
</div>
</div>
</div> </div>
{% endif %} {% endif %}
<!-- Final Feedback Summary --> <!-- Final Feedback Summary -->
{% if final_feedback %} {% if final_feedback %}
<div class="section-card rounded-xl p-6"> <div class="section-card rounded-xl p-6">

View File

@@ -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] 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 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 = None
post_type_analysis = 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": post_type,
"post_type_analysis": post_type_analysis, "post_type_analysis": post_type_analysis,
"final_feedback": final_feedback, "final_feedback": final_feedback,
"style_card": style_card,
"profile_picture_url": profile_picture_url, "profile_picture_url": profile_picture_url,
"profile_picture": profile_picture_url, "profile_picture": profile_picture_url,
"media_items_dict": media_items_dict, "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) return RedirectResponse(url="/company/manage", status_code=302)
# Get employee info + post in parallel # 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_profile(UUID(employee_id)),
db.get_user(UUID(employee_id)), db.get_user(UUID(employee_id)),
db.get_generated_post(UUID(post_id)), db.get_generated_post(UUID(post_id)),
db.get_profile_analysis(UUID(employee_id)),
) )
if not emp_profile: if not emp_profile:
return RedirectResponse(url="/company/manage", status_code=302) 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 display_name=emp_profile.display_name
) )
profile_picture_url = await get_user_avatar(emp_session, emp_profile.id) 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 # Convert media_items to dicts for JSON serialization in template
media_items_dict = [] media_items_dict = []
@@ -3484,6 +3495,7 @@ async def company_manage_post_detail(request: Request, post_id: str, employee_id
"post": post, "post": post,
"media_items_dict": media_items_dict, "media_items_dict": media_items_dict,
"profile_picture_url": profile_picture_url, "profile_picture_url": profile_picture_url,
"style_card": style_card,
"permissions": permissions, "permissions": permissions,
"current_employee_id": employee_id, "current_employee_id": employee_id,
"limit_reached": limit_reached, "limit_reached": limit_reached,
@@ -4373,15 +4385,22 @@ async def chat_generate_post(request: Request):
"relevance": "User-specified content" "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( plan = await orchestrator.generate_post_plan(
topic=topic, topic=topic,
profile_analysis=profile_analysis.full_analysis, profile_analysis=profile_analysis.full_analysis,
company_strategy=company_strategy, company_strategy=company_strategy,
strategy_weight=getattr(post_type, 'strategy_weight', 0.5), strategy_weight=strategy_weight,
user_thoughts=message user_thoughts=message,
post_type_analysis=post_type.analysis if post_type and post_type.analysis else None
) )
if plan: if plan:
topic["post_plan"] = plan topic["content_plan"] = plan
# Generate post # Generate post
post_content = await writer.process( post_content = await writer.process(
@@ -4391,10 +4410,64 @@ async def chat_generate_post(request: Request):
post_type=post_type, post_type=post_type,
user_thoughts=message, # CRITICAL: User's input as primary content user_thoughts=message, # CRITICAL: User's input as primary content
company_strategy=company_strategy, company_strategy=company_strategy,
strategy_weight=post_type.strategy_weight strategy_weight=strategy_weight
) )
post_content = sanitize_post_content(post_content) 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 # Generate conversation ID
import uuid import uuid
conversation_id = str(uuid.uuid4()) conversation_id = str(uuid.uuid4())
@@ -4495,15 +4568,22 @@ async def chat_refine_post(request: Request):
"relevance": "User refinement 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( plan = await orchestrator.generate_post_plan(
topic=topic, topic=topic,
profile_analysis=full_analysis, profile_analysis=full_analysis,
company_strategy=company_strategy, company_strategy=company_strategy,
strategy_weight=getattr(post_type, 'strategy_weight', 0.5), strategy_weight=strategy_weight,
user_thoughts=message user_thoughts=message,
post_type_analysis=post_type.analysis if post_type and post_type.analysis else None
) )
if plan: if plan:
topic["post_plan"] = plan topic["content_plan"] = plan
# Use writer's revision capability # Use writer's revision capability
refined_post = await writer.process( refined_post = await writer.process(
@@ -4515,10 +4595,64 @@ async def chat_refine_post(request: Request):
post_type=post_type, post_type=post_type,
user_thoughts=message, user_thoughts=message,
company_strategy=company_strategy, company_strategy=company_strategy,
strategy_weight=getattr(post_type, 'strategy_weight', 0.5) strategy_weight=strategy_weight
) )
refined_post = sanitize_post_content(refined_post) 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({ return JSONResponse({
"success": True, "success": True,
"post": refined_post, "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"} 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( plan = await orchestrator.generate_post_plan(
topic=topic, topic=topic,
profile_analysis=profile_analysis.full_analysis, profile_analysis=profile_analysis.full_analysis,
company_strategy=company_strategy, company_strategy=company_strategy,
strategy_weight=getattr(post_type, 'strategy_weight', 0.5), strategy_weight=strategy_weight,
user_thoughts=message user_thoughts=message,
post_type_analysis=post_type.analysis if post_type and post_type.analysis else None
) )
if plan: if plan:
topic["post_plan"] = plan topic["content_plan"] = plan
post_content = await writer.process( post_content = await writer.process(
topic=topic, topic=topic,
profile_analysis=profile_analysis.full_analysis, profile_analysis=profile_analysis.full_analysis,
@@ -4831,10 +4972,53 @@ async def company_chat_generate_post(request: Request):
post_type=post_type, post_type=post_type,
user_thoughts=message, user_thoughts=message,
company_strategy=company_strategy, company_strategy=company_strategy,
strategy_weight=post_type.strategy_weight strategy_weight=strategy_weight
) )
post_content = sanitize_post_content(post_content) 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({ return JSONResponse({
"success": True, "success": True,
"post": post_content, "post": post_content,