tried to further improve writing quality
This commit is contained in:
@@ -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."""
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -422,48 +422,14 @@
|
||||
|
||||
<!-- Sidebar -->
|
||||
<div class="space-y-6">
|
||||
<!-- Score Breakdown -->
|
||||
{% if final_feedback and final_feedback.scores %}
|
||||
<!-- Overall Score -->
|
||||
{% if final_feedback and final_feedback.get('overall_score') is not none %}
|
||||
<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>
|
||||
Score-Aufschlüsselung
|
||||
Gesamt-Score
|
||||
</h3>
|
||||
<div class="space-y-4">
|
||||
<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 class="text-3xl font-bold text-brand-highlight">{{ final_feedback.overall_score }}/100</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -488,6 +454,19 @@
|
||||
</div>
|
||||
{% 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 -->
|
||||
<div class="section-card rounded-xl p-6">
|
||||
<h3 class="font-semibold text-white mb-4">Aktionen</h3>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -365,6 +365,20 @@
|
||||
</div>
|
||||
{% 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 -->
|
||||
{% if not permissions or permissions.can_schedule_posts %}
|
||||
<div class="section-card rounded-xl p-6">
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -778,6 +778,20 @@
|
||||
</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) -->
|
||||
<div class="section-card rounded-xl p-6 mb-6">
|
||||
<h3 class="font-semibold text-white mb-4 flex items-center gap-2">
|
||||
@@ -832,45 +846,18 @@
|
||||
|
||||
<!-- Bottom Section: Score, Feedback, Actions -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||
<!-- Score Breakdown -->
|
||||
{% if final_feedback and final_feedback.get('scores') and final_feedback.get('overall_score') is not none %}
|
||||
<!-- Overall Score -->
|
||||
{% if final_feedback and final_feedback.get('overall_score') is not none %}
|
||||
<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>
|
||||
Score: {{ final_feedback.get('overall_score', 0) }}/100
|
||||
Gesamt-Score
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
<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 class="text-3xl font-bold text-brand-highlight">{{ final_feedback.get('overall_score', 0) }}/100</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
<!-- Final Feedback Summary -->
|
||||
{% if final_feedback %}
|
||||
<div class="section-card rounded-xl p-6">
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user