added strtegy import and amployee strategy

This commit is contained in:
2026-03-16 11:21:43 +01:00
parent a3ea774b58
commit 46793f4acf
12 changed files with 1200 additions and 415 deletions

View File

@@ -19,6 +19,9 @@ PERPLEXITY_API_KEY=pplx-your-perplexity-key
# Apify API Key (required for LinkedIn scraping) # Apify API Key (required for LinkedIn scraping)
APIFY_API_KEY=apify_api_your-apify-key APIFY_API_KEY=apify_api_your-apify-key
# NVIDIA API Key (Insights summary)
NVIDIA_API_KEY=nvapi-your-nvidia-key
# =========================================== # ===========================================
# Supabase Database # Supabase Database
# =========================================== # ===========================================
@@ -37,6 +40,9 @@ APIFY_ACTOR_ID=apimaestro~linkedin-profile-posts
DEBUG=false DEBUG=false
LOG_LEVEL=INFO LOG_LEVEL=INFO
# Insights
INSIGHTS_MANUAL_REFRESH_LIMIT_ENABLED=true
# =========================================== # ===========================================
# Email Settings (for sending posts) # Email Settings (for sending posts)
# =========================================== # ===========================================

View File

@@ -7,6 +7,7 @@ from src.agents.writer import WriterAgent
from src.agents.critic import CriticAgent from src.agents.critic import CriticAgent
from src.agents.post_classifier import PostClassifierAgent from src.agents.post_classifier import PostClassifierAgent
from src.agents.post_type_analyzer import PostTypeAnalyzerAgent from src.agents.post_type_analyzer import PostTypeAnalyzerAgent
from src.agents.strategy_importer import StrategyImporterAgent
__all__ = [ __all__ = [
"BaseAgent", "BaseAgent",
@@ -17,4 +18,5 @@ __all__ = [
"CriticAgent", "CriticAgent",
"PostClassifierAgent", "PostClassifierAgent",
"PostTypeAnalyzerAgent", "PostTypeAnalyzerAgent",
"StrategyImporterAgent",
] ]

View File

@@ -0,0 +1,69 @@
"""Company strategy importer agent."""
import json
from typing import Any, Dict, Optional
from src.agents.base import BaseAgent
class StrategyImporterAgent(BaseAgent):
"""Convert a strategy document into structured company strategy fields."""
def __init__(self) -> None:
super().__init__("StrategyImporter")
async def process(self, document_text: str, company_name: Optional[str] = None) -> Dict[str, Any]:
system_prompt = (
"Du bist ein erfahrener Strategy Analyst und strukturierst Unternehmensstrategien "
"für LinkedIn-Content. Du erhältst den Text eines Strategie-Dokuments und musst daraus "
"ein klares, ausführliches JSON im vorgegebenen Format erstellen."
)
user_prompt = f"""
Unternehmensname: {company_name or "Unbekannt"}
Analysiere den folgenden Dokumenttext und extrahiere eine vollständige Unternehmensstrategie.
Die Antwort MUSS valides JSON sein und exakt dieses Schema enthalten:
{{
"mission": "String",
"vision": "String",
"brand_voice": "String",
"tone_guidelines": "String",
"target_audience": "String",
"content_pillars": ["String", "..."],
"dos": ["String", "..."],
"donts": ["String", "..."]
}}
Anforderungen:
- Schreibe auf Deutsch.
- Fülle alle Felder so vollständig wie möglich.
- Nutze ausschließlich Informationen aus dem Dokument. Wenn etwas implizit ist, formuliere es vorsichtig.
- Content-Säulen, Do's und Don'ts sollen konkrete, umsetzbare Punkte sein.
Dokumenttext:
{document_text}
"""
response = await self.call_openai(
system_prompt=system_prompt,
user_prompt=user_prompt,
model="gpt-4o",
temperature=0.2,
response_format={"type": "json_object"}
)
parsed = json.loads(response)
if not isinstance(parsed, dict):
raise ValueError("Ungültiges JSON vom Modell.")
return {
"mission": parsed.get("mission", ""),
"vision": parsed.get("vision", ""),
"brand_voice": parsed.get("brand_voice", ""),
"tone_guidelines": parsed.get("tone_guidelines", ""),
"target_audience": parsed.get("target_audience", ""),
"content_pillars": parsed.get("content_pillars", []) or [],
"dos": parsed.get("dos", []) or [],
"donts": parsed.get("donts", []) or [],
}

View File

@@ -1,6 +1,7 @@
"""Configuration management for LinkedIn Workflow.""" """Configuration management for LinkedIn Workflow."""
from typing import Optional from typing import Optional
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import Field, AliasChoices
from pathlib import Path from pathlib import Path
@@ -61,6 +62,9 @@ class Settings(BaseSettings):
# Token Encryption # Token Encryption
encryption_key: str = "" # Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" encryption_key: str = "" # Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
# NVIDIA (free model for insights summary)
nvidia_api_key: str = ""
# MOCO Integration # MOCO Integration
moco_api_key: str = "" # Token für Authorization-Header moco_api_key: str = "" # Token für Authorization-Header
moco_domain: str = "" # Subdomain: {domain}.mocoapp.com moco_domain: str = "" # Subdomain: {domain}.mocoapp.com
@@ -68,6 +72,13 @@ class Settings(BaseSettings):
# Redis # Redis
redis_url: str = "redis://redis:6379/0" redis_url: str = "redis://redis:6379/0"
scheduler_enabled: bool = False # True only on dedicated scheduler container scheduler_enabled: bool = False # True only on dedicated scheduler container
insights_manual_refresh_limit_enabled: bool = Field(
default=True,
validation_alias=AliasChoices(
"INSIGHTS_MANUAL_REFRESH_LIMIT_ENABLED",
"NSIGHTS_MANUAL_REFRESH_LIMIT_ENABLED"
),
)
# YouTube (optional) # YouTube (optional)
youtube_cookies: str = "" # Raw Cookie header value for transcript fetching youtube_cookies: str = "" # Raw Cookie header value for transcript fetching

View File

@@ -0,0 +1,82 @@
"""Generate a short insights summary using NVIDIA-hosted LLM."""
from __future__ import annotations
from typing import Any, Dict, Optional
from loguru import logger
from openai import OpenAI
from src.config import settings
def _build_prompt(post_insights: Dict[str, Any]) -> str:
return (
"Erstelle eine kurze, prägnante Zusammenfassung (2-4 Sätze) der wichtigsten "
"Insights aus den folgenden LinkedIn-Post-Metriken. Fokus: Trends, auffällige "
"Performance, was gut funktioniert.\n\n"
f"Posts: {post_insights.get('total_posts')}\n"
f"Ø Reaktionen/Post: {post_insights.get('avg_reactions')}\n"
f"Ø Likes: {post_insights.get('avg_likes')}\n"
f"Ø Comments: {post_insights.get('avg_comments')}\n"
f"Ø Shares: {post_insights.get('avg_shares')}\n"
f"Letzte 7 Tage Reaktionen: {post_insights.get('last_7_reactions')}\n"
f"Trend vs Vorwoche (%): {post_insights.get('trend_pct')}\n"
f"Bester Wochentag: {post_insights.get('best_weekday')}\n"
f"Posting-Kadenz/Woche: {post_insights.get('cadence_per_week')}\n"
)
def _is_summary_complete(text: str) -> bool:
if not text:
return False
cleaned = text.strip()
if len(cleaned) < 60:
return False
if cleaned[-1] not in ".!?":
return False
return True
def generate_insights_summary(post_insights: Dict[str, Any]) -> Optional[str]:
"""Generate a short summary for the insights page."""
if not settings.nvidia_api_key:
return None
try:
client = OpenAI(
base_url="https://integrate.api.nvidia.com/v1",
api_key=settings.nvidia_api_key,
)
completion = client.chat.completions.create(
model="deepseek-ai/deepseek-v3.2",
messages=[{"role": "user", "content": _build_prompt(post_insights)}],
temperature=0.6,
top_p=0.95,
max_tokens=300,
)
content = None
if completion and completion.choices:
content = completion.choices[0].message.content
if content:
text = content.strip()
if _is_summary_complete(text):
return text
# Retry once with stricter settings if output looks incomplete
retry = client.chat.completions.create(
model="deepseek-ai/deepseek-v3.2",
messages=[{"role": "user", "content": _build_prompt(post_insights)}],
temperature=0.4,
top_p=0.9,
max_tokens=220,
)
if retry and retry.choices:
text = (retry.choices[0].message.content or "").strip()
if _is_summary_complete(text):
return text
return None
except Exception as e:
logger.warning(f"Failed to generate insights summary: {e}")
return None

View File

@@ -157,16 +157,7 @@
<svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"/>
</svg> </svg>
<span class="sidebar-text">Unternehmensstrategie</span> <span class="sidebar-text">Strategie</span>
</a>
{% endif %}
{% if session and session.account_type == 'employee' %}
<a href="/employee/insights" class="nav-link flex items-center gap-3 px-4 py-3 rounded-lg text-gray-300 hover:bg-brand-bg-light transition-colors {% if page == 'insights' %}active{% endif %}">
<svg class="w-5 h-5 flex-shrink-0" 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>
<span class="sidebar-text">Post Insights</span>
<span class="ml-auto text-[10px] font-semibold px-2 py-0.5 rounded-full bg-brand-highlight text-brand-bg-dark sidebar-text">Neu</span>
</a> </a>
{% endif %} {% endif %}
</nav> </nav>

View File

@@ -4,9 +4,55 @@
{% block content %} {% block content %}
<div class="max-w-3xl mx-auto"> <div class="max-w-3xl mx-auto">
<h1 class="text-2xl font-bold text-white mb-2">Unternehmensstrategie</h1> <div id="strategyImportOverlay" class="fixed inset-0 bg-black/70 backdrop-blur-sm z-50 hidden">
<p class="text-gray-400 mb-8">Diese Strategie wird bei der Erstellung aller LinkedIn-Posts deiner Mitarbeiter berücksichtigt.</p> <div class="flex items-center justify-center min-h-screen p-6">
<div class="card-bg border rounded-2xl p-8 w-full max-w-lg text-center space-y-5">
<div class="w-16 h-16 bg-brand-highlight/20 rounded-full flex items-center justify-center mx-auto">
<svg class="w-8 h-8 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<div>
<h2 class="text-xl font-semibold text-white mb-1">Strategie wird importiert</h2>
<p id="importStatusText" class="text-gray-400 text-sm">PDF wird verarbeitet…</p>
</div>
<div class="w-full bg-brand-bg-dark rounded-full h-3 overflow-hidden border border-brand-bg-light">
<div id="importProgressBar" class="h-full bg-brand-highlight transition-all duration-300" style="width: 10%"></div>
</div>
<div id="importStepText" class="text-xs text-gray-500">Bitte warten das kann einen Moment dauern.</div>
</div>
</div>
</div>
<div id="strategyImportModal" class="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 hidden">
<div class="flex items-center justify-center min-h-screen p-6">
<div class="card-bg border rounded-2xl p-6 w-full max-w-md space-y-4 text-center">
<div class="w-12 h-12 bg-brand-highlight/20 rounded-full flex items-center justify-center mx-auto">
<svg class="w-6 h-6 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
</div>
<h3 id="strategyImportModalTitle" class="text-lg font-semibold text-white">Unternehmensstrategie aktualisiert</h3>
<p id="strategyImportModalBody" class="text-gray-400 text-sm">Bitte prüfen und speichern.</p>
<button id="strategyImportModalClose" class="btn-primary font-medium py-2.5 px-6 rounded-lg transition-colors">
Verstanden
</button>
</div>
</div>
</div>
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between mb-2">
<h1 class="text-2xl font-bold text-white">Unternehmensstrategie</h1>
<div class="flex items-center gap-2">
<input type="file" id="strategyImportInput" accept=".pdf" class="hidden">
<button type="button" id="strategyImportBtn" class="btn-primary font-medium py-2.5 px-4 rounded-lg transition-colors">
Importieren
</button>
</div>
</div>
<p class="text-gray-400 mb-2">Diese Strategie wird bei der Erstellung aller LinkedIn-Posts deiner Mitarbeiter berücksichtigt.</p>
<p class="text-gray-500 text-sm mb-8">PDF hochladen, analysieren lassen und die Felder automatisch befüllen.</p>
<div id="strategyFormWrapper">
{% if success %} {% if success %}
<div class="bg-green-900/50 border border-green-500 text-green-200 px-4 py-3 rounded-lg mb-6"> <div class="bg-green-900/50 border border-green-500 text-green-200 px-4 py-3 rounded-lg mb-6">
Strategie erfolgreich gespeichert! Strategie erfolgreich gespeichert!
@@ -201,6 +247,7 @@
</div> </div>
</form> </form>
</div> </div>
</div>
<script> <script>
function addPillar() { function addPillar() {
@@ -256,5 +303,149 @@ function addDont() {
`; `;
container.appendChild(div); container.appendChild(div);
} }
function buildList(containerId, inputName, values, placeholder, isSmall) {
const container = document.getElementById(containerId);
if (!container) return;
container.innerHTML = '';
const items = (values && values.length) ? values : [''];
items.forEach((value) => {
const div = document.createElement('div');
div.className = 'flex gap-2';
const textClass = isSmall ? 'text-sm' : '';
div.innerHTML = `
<input type="text" name="${inputName}" value="${value || ''}"
class="flex-1 input-bg border rounded-lg px-4 py-2 text-white ${textClass}"
placeholder="${placeholder}">
<button type="button" onclick="this.parentElement.remove()"
class="px-2 py-2 text-red-400 hover:text-red-300">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
`;
container.appendChild(div);
});
}
function setValue(id, value) {
const el = document.getElementById(id);
if (el) el.value = value || '';
}
function showImportOverlay() {
const overlay = document.getElementById('strategyImportOverlay');
const wrapper = document.getElementById('strategyFormWrapper');
if (overlay) overlay.classList.remove('hidden');
if (wrapper) wrapper.classList.add('hidden');
}
function hideImportOverlay() {
const overlay = document.getElementById('strategyImportOverlay');
const wrapper = document.getElementById('strategyFormWrapper');
if (overlay) overlay.classList.add('hidden');
if (wrapper) wrapper.classList.remove('hidden');
}
function updateImportProgress(percent, statusText, stepText) {
const bar = document.getElementById('importProgressBar');
const status = document.getElementById('importStatusText');
const step = document.getElementById('importStepText');
if (bar) bar.style.width = `${percent}%`;
if (status && statusText) status.textContent = statusText;
if (step && stepText) step.textContent = stepText;
}
function showImportModal(title, body) {
const modal = document.getElementById('strategyImportModal');
const titleEl = document.getElementById('strategyImportModalTitle');
const bodyEl = document.getElementById('strategyImportModalBody');
if (titleEl) titleEl.textContent = title;
if (bodyEl) bodyEl.textContent = body;
if (modal) modal.classList.remove('hidden');
}
function hideImportModal() {
const modal = document.getElementById('strategyImportModal');
if (modal) modal.classList.add('hidden');
}
async function importStrategy(file) {
const btn = document.getElementById('strategyImportBtn');
if (!file) return;
btn.disabled = true;
btn.classList.add('opacity-50', 'cursor-not-allowed');
showImportOverlay();
updateImportProgress(15, 'PDF wird verarbeitet…', 'Text wird extrahiert');
try {
const steps = [
{ percent: 35, status: 'Dokument wird analysiert…', step: 'Inhalte werden strukturiert' },
{ percent: 60, status: 'Strategie wird aufgebaut…', step: 'Felder werden zugeordnet' },
{ percent: 85, status: 'Format wird finalisiert…', step: 'Qualitätscheck läuft' }
];
let stepIndex = 0;
const stepTimer = setInterval(() => {
if (stepIndex >= steps.length) {
clearInterval(stepTimer);
return;
}
const step = steps[stepIndex];
updateImportProgress(step.percent, step.status, step.step);
stepIndex += 1;
}, 900);
const formData = new FormData();
formData.append('file', file);
const response = await fetch('/company/strategy/import', {
method: 'POST',
body: formData
});
const data = await response.json();
if (!response.ok || !data.success) {
const message = data.detail || data.error || 'Import fehlgeschlagen.';
updateImportProgress(100, 'Import fehlgeschlagen', 'Bitte erneut versuchen.');
showImportModal('Import fehlgeschlagen', message);
return;
}
const strategy = data.strategy || {};
setValue('mission', strategy.mission);
setValue('vision', strategy.vision);
setValue('brand_voice', strategy.brand_voice);
setValue('tone_guidelines', strategy.tone_guidelines);
setValue('target_audience', strategy.target_audience);
buildList('content-pillars', 'content_pillar', strategy.content_pillars, 'z.B. Innovation, Nachhaltigkeit, Teamkultur', false);
buildList('dos-list', 'do_item', strategy.dos, 'z.B. Erfolge feiern', true);
buildList('donts-list', 'dont_item', strategy.donts, 'z.B. Konkurrenz kritisieren', true);
updateImportProgress(100, 'Import abgeschlossen', 'Bitte prüfen und speichern.');
showImportModal('Unternehmensstrategie aktualisiert', 'Bitte prüfen und speichern.');
} catch (err) {
updateImportProgress(100, 'Import fehlgeschlagen', 'Bitte erneut versuchen.');
showImportModal('Import fehlgeschlagen', 'Bitte erneut versuchen.');
} finally {
btn.disabled = false;
btn.classList.remove('opacity-50', 'cursor-not-allowed');
hideImportOverlay();
}
}
const importBtn = document.getElementById('strategyImportBtn');
const importInput = document.getElementById('strategyImportInput');
if (importBtn && importInput) {
importBtn.addEventListener('click', () => importInput.click());
importInput.addEventListener('change', () => {
const file = importInput.files && importInput.files[0];
if (file) importStrategy(file);
importInput.value = '';
});
}
const modalClose = document.getElementById('strategyImportModalClose');
if (modalClose) modalClose.addEventListener('click', hideImportModal);
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -88,7 +88,7 @@
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg> </svg>
Unternehmensstrategie Strategie
</a> </a>
<a href="/settings" class="sidebar-link {% if '/settings' in request.url.path %}active{% endif %}"> <a href="/settings" class="sidebar-link {% if '/settings' in request.url.path %}active{% endif %}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">

View File

@@ -106,7 +106,7 @@
</div> </div>
<div> <div>
<p class="text-white font-medium">Strategie</p> <p class="text-white font-medium">Strategie</p>
<p class="text-gray-400 text-sm">Richtlinien</p> <p class="text-gray-400 text-sm">Profil-Analyse</p>
</div> </div>
</a> </a>
</div> </div>

View File

@@ -1,237 +0,0 @@
{% extends "base.html" %}
{% block title %}Post Insights{% endblock %}
{% block head %}
<script src="https://cdn.jsdelivr.net/npm/apexcharts"></script>
{% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto">
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-white">Post Insights</h1>
<p class="text-gray-400 text-sm mt-1">Tägliche Auswertung deiner LinkedIn-Posts</p>
</div>
<button id="insights-refresh-btn" class="px-4 py-2 rounded-lg text-sm font-medium bg-brand-highlight/20 text-brand-highlight hover:bg-brand-highlight/30 transition-colors">
Jetzt aktualisieren
</button>
</div>
{% if not linkedin_account %}
<div class="card-bg border rounded-xl p-6 text-center">
<p class="text-gray-300 mb-2">Verbinde deinen LinkedIn Account, damit wir täglich Post-Insights aktualisieren können.</p>
<a href="/settings" class="inline-block mt-2 text-brand-highlight hover:underline">Zum LinkedIn Login</a>
</div>
{% elif not post_insights or not post_insights.has_data %}
<div class="card-bg border rounded-xl p-6 text-center">
<p class="text-gray-300">Noch keine Insights vorhanden. Der tägliche Import läuft in den nächsten 24 Stunden.</p>
</div>
{% else %}
<div class="grid md:grid-cols-3 gap-6 mb-8">
<div class="card-bg border rounded-xl p-6">
<p class="text-gray-400 text-sm">Posts getrackt</p>
<p class="text-3xl font-bold text-white">{{ post_insights.total_posts }}</p>
</div>
<div class="card-bg border rounded-xl p-6">
<p class="text-gray-400 text-sm">Ø Reaktionen/Post</p>
<p class="text-3xl font-bold text-white">{{ post_insights.avg_reactions }}</p>
<p class="text-xs text-gray-500 mt-1">Likes {{ post_insights.avg_likes }} · Comments {{ post_insights.avg_comments }} · Shares {{ post_insights.avg_shares }}</p>
</div>
<div class="card-bg border rounded-xl p-6">
<p class="text-gray-400 text-sm">Letzte 7 Tage</p>
<p class="text-3xl font-bold text-white">{{ post_insights.last_7_reactions }}</p>
{% if post_insights.trend_pct is not none %}
<p class="text-xs mt-1 {% if post_insights.trend_pct >= 0 %}text-green-400{% else %}text-red-400{% endif %}">
{% if post_insights.trend_pct >= 0 %}+{% endif %}{{ post_insights.trend_pct }}% vs. Vorwoche
</p>
{% endif %}
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
<div class="card-bg border rounded-xl p-6">
<h2 class="text-lg font-semibold text-white mb-4">Engagement-Entwicklung (7 Tage)</h2>
<div id="chart-engagement"></div>
</div>
<div class="card-bg border rounded-xl p-6">
<h2 class="text-lg font-semibold text-white mb-4">Reaktions-Mix</h2>
<div id="chart-reactions"></div>
</div>
</div>
<div class="grid md:grid-cols-2 gap-6 mb-8">
<div class="card-bg border rounded-xl p-6">
<h3 class="text-lg font-semibold text-white mb-4">Wochentag-Performance</h3>
<div id="chart-weekday"></div>
</div>
<div class="card-bg border rounded-xl p-6">
<h3 class="text-lg font-semibold text-white mb-4">Performance-Driver</h3>
<div class="text-sm text-gray-400 space-y-3">
<div class="flex items-center justify-between">
<span>Bester Wochentag</span>
<span class="text-gray-200">{{ post_insights.best_weekday or 'N/A' }}</span>
</div>
<div class="flex items-center justify-between">
<span>Posting-Kadenz</span>
<span class="text-gray-200">{{ post_insights.cadence_per_week or 'N/A' }} Posts/Woche</span>
</div>
<div class="flex items-center justify-between">
<span>Letzter Snapshot</span>
<span class="text-gray-200">{{ post_insights.latest_snapshot_date.strftime('%d.%m.%Y') if post_insights.latest_snapshot_date else 'N/A' }}</span>
</div>
</div>
</div>
</div>
<div class="card-bg border rounded-xl p-6 mb-8">
<h2 class="text-lg font-semibold text-white mb-4">Top Posts (Engagement)</h2>
<div class="space-y-3">
{% for post in post_insights.top_posts %}
<div class="p-4 bg-brand-bg-dark rounded-lg">
<p class="text-white text-sm line-clamp-2">{{ post.text[:180] }}{% if post.text|length > 180 %}...{% endif %}</p>
<div class="text-xs text-gray-500 mt-2 flex items-center gap-3">
<span>{{ post.post_date.strftime('%d.%m.%Y') if post.post_date else 'N/A' }}</span>
<span>Likes {{ post.likes }}</span>
<span>Comments {{ post.comments }}</span>
<span>Shares {{ post.shares }}</span>
<span>Score {{ post.engagement_score }}</span>
{% if post.post_url %}
<a href="{{ post.post_url }}" target="_blank" class="text-brand-highlight hover:underline">Öffnen</a>
{% endif %}
</div>
</div>
{% endfor %}
</div>
</div>
<div class="card-bg border rounded-xl p-6">
<h2 class="text-lg font-semibold text-white mb-4">Post-Länge vs. Engagement</h2>
<div id="chart-length"></div>
</div>
{% endif %}
</div>
{% endblock %}
{% block scripts %}
<script>
document.addEventListener('DOMContentLoaded', function() {
const refreshBtn = document.getElementById('insights-refresh-btn');
if (refreshBtn) {
refreshBtn.addEventListener('click', async () => {
refreshBtn.disabled = true;
refreshBtn.textContent = 'Aktualisiere...';
try {
const res = await fetch('/api/insights/refresh', { method: 'POST' });
if (res.ok) {
refreshBtn.textContent = 'Aktualisiert (heute)';
setTimeout(() => window.location.reload(), 800);
} else if (res.status === 429) {
refreshBtn.textContent = 'Heute bereits genutzt';
} else if (res.status === 400) {
refreshBtn.textContent = 'Kein LinkedIn Login';
} else {
refreshBtn.textContent = 'Fehler';
}
} catch (e) {
refreshBtn.textContent = 'Fehler';
} finally {
setTimeout(() => { refreshBtn.disabled = false; }, 2000);
}
});
}
{% if post_insights and post_insights.has_data %}
const seriesData = {{ post_insights.series_chart | tojson }};
const reactionMix = {{ post_insights.reaction_mix | tojson }};
const weekdayBreakdown = {{ post_insights.weekday_breakdown | tojson }};
const lengthBuckets = {{ post_insights.length_buckets | tojson }};
if (seriesData && seriesData.length) {
const dates = seriesData.map(d => d.date.slice(5, 10));
const reactions = seriesData.map(d => d.reactions);
const likes = seriesData.map(d => d.likes);
const comments = seriesData.map(d => d.comments);
const shares = seriesData.map(d => d.shares);
const engagementChart = new ApexCharts(document.querySelector('#chart-engagement'), {
chart: { type: 'area', height: 260, toolbar: { show: false }, foreColor: '#e5e7eb', background: 'transparent' },
stroke: { curve: 'smooth', width: 2 },
dataLabels: { enabled: false },
colors: ['#e94560', '#60a5fa', '#f59e0b', '#10b981'],
series: [
{ name: 'Reaktionen', data: reactions },
{ name: 'Likes', data: likes },
{ name: 'Comments', data: comments },
{ name: 'Shares', data: shares }
],
xaxis: { categories: dates, labels: { style: { colors: '#9ca3af' } } },
yaxis: { labels: { style: { colors: '#9ca3af' } } },
grid: { borderColor: 'rgba(233,69,96,0.12)' },
legend: { labels: { colors: '#e5e7eb' } },
fill: { type: 'gradient', gradient: { opacityFrom: 0.35, opacityTo: 0.05 } },
tooltip: { theme: 'dark' }
});
engagementChart.render();
}
if (reactionMix && reactionMix.length) {
const labels = reactionMix.map(r => r.name);
const values = reactionMix.map(r => r.count);
const reactionsChart = new ApexCharts(document.querySelector('#chart-reactions'), {
chart: { type: 'donut', height: 260, foreColor: '#e5e7eb', background: 'transparent' },
labels,
series: values,
legend: { labels: { colors: '#e5e7eb' } },
dataLabels: { style: { colors: ['#111827'] } },
colors: ['#e94560', '#60a5fa', '#f59e0b', '#10b981', '#a855f7', '#f97316'],
stroke: { colors: ['#1a1a2e'] }
});
reactionsChart.render();
}
if (weekdayBreakdown && weekdayBreakdown.length) {
const dayLabels = weekdayBreakdown.map(d => d.day.slice(0, 3));
const avgEngagement = weekdayBreakdown.map(d => d.avg_engagement);
const counts = weekdayBreakdown.map(d => d.count);
const weekdayChart = new ApexCharts(document.querySelector('#chart-weekday'), {
chart: { type: 'bar', height: 240, toolbar: { show: false }, foreColor: '#e5e7eb', background: 'transparent' },
series: [
{ name: 'Ø Engagement', data: avgEngagement },
{ name: 'Posts', data: counts }
],
plotOptions: { bar: { columnWidth: '45%', borderRadius: 4 } },
xaxis: { categories: dayLabels, labels: { style: { colors: '#9ca3af' } } },
yaxis: { labels: { style: { colors: '#9ca3af' } } },
grid: { borderColor: 'rgba(233,69,96,0.12)' },
colors: ['#e94560', '#60a5fa'],
legend: { labels: { colors: '#e5e7eb' } },
tooltip: { theme: 'dark' }
});
weekdayChart.render();
}
if (lengthBuckets) {
const bucketLabels = Object.keys(lengthBuckets);
const counts = bucketLabels.map(k => lengthBuckets[k].count || 0);
const avgScores = bucketLabels.map(k => lengthBuckets[k].avg_engagement || 0);
const lengthChart = new ApexCharts(document.querySelector('#chart-length'), {
chart: { type: 'bar', height: 240, toolbar: { show: false }, foreColor: '#e5e7eb', background: 'transparent' },
series: [
{ name: 'Posts', data: counts },
{ name: 'Ø Score', data: avgScores }
],
plotOptions: { bar: { columnWidth: '45%', borderRadius: 4 } },
xaxis: { categories: bucketLabels, labels: { style: { colors: '#9ca3af' } } },
yaxis: { labels: { style: { colors: '#9ca3af' } } },
grid: { borderColor: 'rgba(233,69,96,0.12)' },
colors: ['#10b981', '#f59e0b'],
legend: { labels: { colors: '#e5e7eb' } },
tooltip: { theme: 'dark' }
});
lengthChart.render();
}
{% endif %}
});
</script>
{% endblock %}

View File

@@ -1,139 +1,723 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Unternehmensstrategie - {{ session.company_name }}{% endblock %} {% block title %}Strategie{% endblock %}
{% block content %} {% block content %}
<div class="max-w-3xl mx-auto"> <div class="max-w-4xl mx-auto space-y-6">
<div class="mb-8"> <div class="card-bg border rounded-2xl p-6 relative overflow-hidden">
<h1 class="text-2xl font-bold text-white mb-2">Unternehmensstrategie</h1> <div class="absolute -right-20 -top-16 w-56 h-56 bg-brand-highlight/10 rounded-full blur-3xl"></div>
<p class="text-gray-400">Diese Strategie wird bei der Erstellung deiner LinkedIn-Posts berücksichtigt.</p> <div class="relative">
{% if session.company_name %} <div class="flex items-center gap-3 mb-3">
<p class="text-brand-highlight text-sm mt-2">Richtlinien von {{ session.company_name }}</p> <div class="w-10 h-10 rounded-xl bg-brand-highlight/20 flex items-center justify-center">
<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="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<div>
<h1 class="text-2xl font-bold text-white">Strategie</h1>
<p class="text-gray-400">Strukturierte Profil-Analyse basierend auf deinen LinkedIn-Posts.</p>
</div>
</div>
{% if analysis_created_at %}
<div class="inline-flex items-center gap-2 text-xs text-gray-300 bg-brand-bg/60 border border-brand-bg-light rounded-full px-3 py-1">
<span class="w-1.5 h-1.5 bg-green-400 rounded-full"></span>
Letzte Analyse: {{ analysis_created_at.strftime('%d.%m.%Y %H:%M') }}
</div>
{% endif %} {% endif %}
</div> </div>
</div>
{% if not strategy or not strategy.mission %} {% if success %}
<div class="card-bg border rounded-xl p-8 text-center"> <div class="bg-green-900/50 border border-green-500 text-green-200 px-4 py-3 rounded-lg">
Profil-Analyse erfolgreich gespeichert!
</div>
{% endif %}
{% if error %}
<div class="bg-red-900/50 border border-red-500 text-red-200 px-4 py-3 rounded-lg">
{{ error }}
</div>
{% endif %}
{% if not profile_analysis %}
<div class="card-bg border rounded-2xl p-8 text-center">
<div class="w-16 h-16 bg-yellow-500/20 rounded-full flex items-center justify-center mx-auto mb-4"> <div class="w-16 h-16 bg-yellow-500/20 rounded-full flex items-center justify-center mx-auto mb-4">
<svg class="w-8 h-8 text-yellow-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-8 h-8 text-yellow-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg> </svg>
</div> </div>
<p class="text-gray-400">Dein Unternehmen hat noch keine Strategie definiert.</p> <p class="text-gray-300 mb-2">Noch keine Profil-Analyse vorhanden.</p>
<p class="text-gray-500 text-sm mt-2">Wende dich an deinen Administrator, um die Unternehmensstrategie einzurichten.</p> <p class="text-gray-500 text-sm">Bitte wende dich an den Administrator, um eine Analyse zu starten.</p>
</div>
{% else %}
<div class="space-y-6">
<!-- Mission & Vision -->
<div class="card-bg border rounded-xl p-6">
<h2 class="text-lg font-semibold text-white mb-4">Mission & Vision</h2>
<div class="space-y-4">
{% if strategy.mission %}
<div>
<p class="text-sm font-medium text-gray-400 mb-1">Mission</p>
<p class="text-white bg-brand-bg-dark rounded-lg p-3">{{ strategy.mission }}</p>
</div>
{% endif %}
{% if strategy.vision %}
<div>
<p class="text-sm font-medium text-gray-400 mb-1">Vision</p>
<p class="text-white bg-brand-bg-dark rounded-lg p-3">{{ strategy.vision }}</p>
</div>
{% endif %}
</div>
</div>
<!-- Brand Voice -->
{% if strategy.brand_voice or strategy.tone_guidelines %}
<div class="card-bg border rounded-xl p-6">
<h2 class="text-lg font-semibold text-white mb-4">Brand Voice</h2>
<div class="space-y-4">
{% if strategy.brand_voice %}
<div>
<p class="text-sm font-medium text-gray-400 mb-1">Markenstimme</p>
<p class="text-white bg-brand-bg-dark rounded-lg p-3">{{ strategy.brand_voice }}</p>
</div>
{% endif %}
{% if strategy.tone_guidelines %}
<div>
<p class="text-sm font-medium text-gray-400 mb-1">Tonalität-Richtlinien</p>
<p class="text-white bg-brand-bg-dark rounded-lg p-3">{{ strategy.tone_guidelines }}</p>
</div>
{% endif %}
</div>
</div> </div>
{% endif %} {% endif %}
<!-- Target Audience --> <form id="strategyForm" method="POST" action="/employee/strategy" class="space-y-6">
{% if strategy.target_audience %} <input type="hidden" id="analysis_original_json" value="{{ analysis_json | e }}">
<div class="card-bg border rounded-xl p-6"> <input type="hidden" id="analysis_json" name="analysis_json" value="">
<h2 class="text-lg font-semibold text-white mb-4">Zielgruppe</h2>
<p class="text-white bg-brand-bg-dark rounded-lg p-3">{{ strategy.target_audience }}</p>
</div>
{% endif %}
<!-- Content Pillars --> <div class="card-bg border rounded-2xl p-6">
{% if strategy.content_pillars and strategy.content_pillars|length > 0 %} <div class="flex items-center gap-3 mb-4">
<div class="card-bg border rounded-xl p-6"> <div class="w-9 h-9 rounded-lg bg-brand-highlight/15 flex items-center justify-center">
<h2 class="text-lg font-semibold text-white mb-4">Content-Säulen</h2> <svg class="w-4 h-4 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<p class="text-gray-400 text-sm mb-4">Die Hauptthemen, über die das Unternehmen postet.</p> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 5H9m6 4H9m6 4H9m6 4H9"/>
<div class="flex flex-wrap gap-2"> </svg>
{% for pillar in strategy.content_pillars %} </div>
<span class="px-3 py-2 bg-brand-highlight/20 border border-brand-highlight/30 rounded-lg text-brand-highlight text-sm">{{ pillar }}</span> <h2 class="text-lg font-semibold text-white">Schreibstil</h2>
</div>
<div class="grid md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-300 mb-1">Tonalität</label>
<input type="text" id="writing_tone" class="w-full input-bg border rounded-lg px-4 py-2 text-white"
value="{{ profile_analysis.writing_style.tone if profile_analysis and profile_analysis.writing_style else '' }}">
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-1">Perspektive</label>
<input type="text" id="writing_perspective" class="w-full input-bg border rounded-lg px-4 py-2 text-white"
value="{{ profile_analysis.writing_style.perspective if profile_analysis and profile_analysis.writing_style else '' }}">
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-1">Ansprache</label>
<input type="text" id="writing_form_of_address" class="w-full input-bg border rounded-lg px-4 py-2 text-white"
value="{{ profile_analysis.writing_style.form_of_address if profile_analysis and profile_analysis.writing_style else '' }}">
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-1">Satzdynamik</label>
<input type="text" id="writing_sentence_dynamics" class="w-full input-bg border rounded-lg px-4 py-2 text-white"
value="{{ profile_analysis.writing_style.sentence_dynamics if profile_analysis and profile_analysis.writing_style else '' }}">
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-1">Ø Post-Länge</label>
<input type="text" id="writing_average_post_length" class="w-full input-bg border rounded-lg px-4 py-2 text-white"
value="{{ profile_analysis.writing_style.average_post_length if profile_analysis and profile_analysis.writing_style else '' }}">
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-1">Ø Wortanzahl</label>
<input type="number" id="writing_average_word_count" class="w-full input-bg border rounded-lg px-4 py-2 text-white"
value="{{ profile_analysis.writing_style.average_word_count if profile_analysis and profile_analysis.writing_style else '' }}">
</div>
</div>
</div>
<div class="card-bg border rounded-2xl p-6">
<div class="flex items-center gap-3 mb-4">
<div class="w-9 h-9 rounded-lg bg-brand-highlight/15 flex items-center justify-center">
<svg class="w-4 h-4 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 11c0 1.657-1.343 3-3 3S6 12.657 6 11s1.343-3 3-3 3 1.343 3 3zm6 7a6 6 0 00-12 0"/>
</svg>
</div>
<h2 class="text-lg font-semibold text-white">Sprachlicher Fingerabdruck</h2>
</div>
<div class="grid md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-300 mb-1">Energie-Level (1-10)</label>
<input type="number" id="linguistic_energy_level" min="1" max="10"
class="w-full input-bg border rounded-lg px-4 py-2 text-white"
value="{{ profile_analysis.linguistic_fingerprint.energy_level if profile_analysis and profile_analysis.linguistic_fingerprint else '' }}">
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-1">Formalität (1-10)</label>
<input type="number" id="linguistic_formality_level" min="1" max="10"
class="w-full input-bg border rounded-lg px-4 py-2 text-white"
value="{{ profile_analysis.linguistic_fingerprint.formality_level if profile_analysis and profile_analysis.linguistic_fingerprint else '' }}">
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-300 mb-1">Ausrufe / Betonung</label>
<textarea id="linguistic_shouting_usage" rows="2"
class="w-full input-bg border rounded-lg px-4 py-2 text-white">{{ profile_analysis.linguistic_fingerprint.shouting_usage if profile_analysis and profile_analysis.linguistic_fingerprint else '' }}</textarea>
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-300 mb-1">Interpunktion</label>
<textarea id="linguistic_punctuation_patterns" rows="2"
class="w-full input-bg border rounded-lg px-4 py-2 text-white">{{ profile_analysis.linguistic_fingerprint.punctuation_patterns if profile_analysis and profile_analysis.linguistic_fingerprint else '' }}</textarea>
</div>
</div>
<div class="mt-4">
<label class="block text-sm font-medium text-gray-300 mb-2">Signature Phrases</label>
<div id="signature_phrases" class="space-y-2">
{% for phrase in profile_analysis.linguistic_fingerprint.signature_phrases or [] %}
<div class="flex gap-2">
<input type="text" class="flex-1 input-bg border rounded-lg px-4 py-2 text-white" value="{{ phrase }}">
<button type="button" onclick="this.parentElement.remove()" class="px-3 py-2 text-red-400 hover:text-red-300"></button>
</div>
{% endfor %} {% endfor %}
</div> {% if not profile_analysis or not profile_analysis.linguistic_fingerprint or not profile_analysis.linguistic_fingerprint.signature_phrases %}
<div class="flex gap-2">
<input type="text" class="flex-1 input-bg border rounded-lg px-4 py-2 text-white" placeholder="Beispielphrase">
<button type="button" onclick="this.parentElement.remove()" class="px-3 py-2 text-red-400 hover:text-red-300"></button>
</div> </div>
{% endif %} {% endif %}
</div>
<button type="button" onclick="addListItem('signature_phrases', 'Beispielphrase')" class="mt-2 text-brand-highlight text-sm hover:underline">Hinzufügen</button>
</div>
<!-- Do's and Don'ts --> <div class="mt-4">
{% if (strategy.dos and strategy.dos|length > 0) or (strategy.donts and strategy.donts|length > 0) %} <label class="block text-sm font-medium text-gray-300 mb-2">Narrative Anchors</label>
<div id="narrative_anchors" class="space-y-2">
{% for phrase in profile_analysis.linguistic_fingerprint.narrative_anchors or [] %}
<div class="flex gap-2">
<input type="text" class="flex-1 input-bg border rounded-lg px-4 py-2 text-white" value="{{ phrase }}">
<button type="button" onclick="this.parentElement.remove()" class="px-3 py-2 text-red-400 hover:text-red-300"></button>
</div>
{% endfor %}
{% if not profile_analysis or not profile_analysis.linguistic_fingerprint or not profile_analysis.linguistic_fingerprint.narrative_anchors %}
<div class="flex gap-2">
<input type="text" class="flex-1 input-bg border rounded-lg px-4 py-2 text-white" placeholder="Story-Element">
<button type="button" onclick="this.parentElement.remove()" class="px-3 py-2 text-red-400 hover:text-red-300"></button>
</div>
{% endif %}
</div>
<button type="button" onclick="addListItem('narrative_anchors', 'Story-Element')" class="mt-2 text-brand-highlight text-sm hover:underline">Hinzufügen</button>
</div>
</div>
<div class="card-bg border rounded-2xl p-6">
<div class="flex items-center gap-3 mb-4">
<div class="w-9 h-9 rounded-lg bg-brand-highlight/15 flex items-center justify-center">
<svg class="w-4 h-4 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7h16M4 12h16M4 17h10"/>
</svg>
</div>
<h2 class="text-lg font-semibold text-white">Phrasen-Bibliothek</h2>
</div>
<div class="grid md:grid-cols-2 gap-6"> <div class="grid md:grid-cols-2 gap-6">
<!-- Do's --> <div>
{% if strategy.dos and strategy.dos|length > 0 %} <label class="block text-sm font-medium text-gray-300 mb-2">Hook-Phrasen</label>
<div class="card-bg border rounded-xl p-6"> <div id="hook_phrases" class="space-y-2">
<h2 class="text-lg font-semibold text-green-400 mb-4 flex items-center gap-2"> {% for phrase in profile_analysis.phrase_library.hook_phrases or [] %}
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <div class="flex gap-2">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/> <input type="text" class="flex-1 input-bg border rounded-lg px-4 py-2 text-white" value="{{ phrase }}">
</svg> <button type="button" onclick="this.parentElement.remove()" class="px-3 py-2 text-red-400 hover:text-red-300"></button>
Do's </div>
</h2>
<ul class="space-y-2">
{% for do in strategy.dos %}
<li class="flex items-start gap-2 text-white text-sm">
<svg class="w-4 h-4 text-green-400 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
{{ do }}
</li>
{% endfor %} {% endfor %}
</ul> {% if not profile_analysis or not profile_analysis.phrase_library or not profile_analysis.phrase_library.hook_phrases %}
<div class="flex gap-2">
<input type="text" class="flex-1 input-bg border rounded-lg px-4 py-2 text-white" placeholder="Hook">
<button type="button" onclick="this.parentElement.remove()" class="px-3 py-2 text-red-400 hover:text-red-300"></button>
</div> </div>
{% endif %} {% endif %}
</div>
<button type="button" onclick="addListItem('hook_phrases', 'Hook')" class="mt-2 text-brand-highlight text-sm hover:underline">Hinzufügen</button>
</div>
<!-- Don'ts --> <div>
{% if strategy.donts and strategy.donts|length > 0 %} <label class="block text-sm font-medium text-gray-300 mb-2">Transition-Phrasen</label>
<div class="card-bg border rounded-xl p-6"> <div id="transition_phrases" class="space-y-2">
<h2 class="text-lg font-semibold text-red-400 mb-4 flex items-center gap-2"> {% for phrase in profile_analysis.phrase_library.transition_phrases or [] %}
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <div class="flex gap-2">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/> <input type="text" class="flex-1 input-bg border rounded-lg px-4 py-2 text-white" value="{{ phrase }}">
</svg> <button type="button" onclick="this.parentElement.remove()" class="px-3 py-2 text-red-400 hover:text-red-300"></button>
Don'ts </div>
</h2>
<ul class="space-y-2">
{% for dont in strategy.donts %}
<li class="flex items-start gap-2 text-white text-sm">
<svg class="w-4 h-4 text-red-400 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
{{ dont }}
</li>
{% endfor %} {% endfor %}
</ul> {% if not profile_analysis or not profile_analysis.phrase_library or not profile_analysis.phrase_library.transition_phrases %}
<div class="flex gap-2">
<input type="text" class="flex-1 input-bg border rounded-lg px-4 py-2 text-white" placeholder="Übergang">
<button type="button" onclick="this.parentElement.remove()" class="px-3 py-2 text-red-400 hover:text-red-300"></button>
</div> </div>
{% endif %} {% endif %}
</div> </div>
{% endif %} <button type="button" onclick="addListItem('transition_phrases', 'Übergang')" class="mt-2 text-brand-highlight text-sm hover:underline">Hinzufügen</button>
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">Emotionale Ausdrücke</label>
<div id="emotional_expressions" class="space-y-2">
{% for phrase in profile_analysis.phrase_library.emotional_expressions or [] %}
<div class="flex gap-2">
<input type="text" class="flex-1 input-bg border rounded-lg px-4 py-2 text-white" value="{{ phrase }}">
<button type="button" onclick="this.parentElement.remove()" class="px-3 py-2 text-red-400 hover:text-red-300"></button>
</div>
{% endfor %}
{% if not profile_analysis or not profile_analysis.phrase_library or not profile_analysis.phrase_library.emotional_expressions %}
<div class="flex gap-2">
<input type="text" class="flex-1 input-bg border rounded-lg px-4 py-2 text-white" placeholder="Ausdruck">
<button type="button" onclick="this.parentElement.remove()" class="px-3 py-2 text-red-400 hover:text-red-300"></button>
</div> </div>
{% endif %} {% endif %}
</div> </div>
<button type="button" onclick="addListItem('emotional_expressions', 'Ausdruck')" class="mt-2 text-brand-highlight text-sm hover:underline">Hinzufügen</button>
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">CTA-Phrasen</label>
<div id="cta_phrases" class="space-y-2">
{% for phrase in profile_analysis.phrase_library.cta_phrases or [] %}
<div class="flex gap-2">
<input type="text" class="flex-1 input-bg border rounded-lg px-4 py-2 text-white" value="{{ phrase }}">
<button type="button" onclick="this.parentElement.remove()" class="px-3 py-2 text-red-400 hover:text-red-300"></button>
</div>
{% endfor %}
{% if not profile_analysis or not profile_analysis.phrase_library or not profile_analysis.phrase_library.cta_phrases %}
<div class="flex gap-2">
<input type="text" class="flex-1 input-bg border rounded-lg px-4 py-2 text-white" placeholder="CTA">
<button type="button" onclick="this.parentElement.remove()" class="px-3 py-2 text-red-400 hover:text-red-300"></button>
</div>
{% endif %}
</div>
<button type="button" onclick="addListItem('cta_phrases', 'CTA')" class="mt-2 text-brand-highlight text-sm hover:underline">Hinzufügen</button>
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">Filler-Ausdrücke</label>
<div id="filler_expressions" class="space-y-2">
{% for phrase in profile_analysis.phrase_library.filler_expressions or [] %}
<div class="flex gap-2">
<input type="text" class="flex-1 input-bg border rounded-lg px-4 py-2 text-white" value="{{ phrase }}">
<button type="button" onclick="this.parentElement.remove()" class="px-3 py-2 text-red-400 hover:text-red-300"></button>
</div>
{% endfor %}
{% if not profile_analysis or not profile_analysis.phrase_library or not profile_analysis.phrase_library.filler_expressions %}
<div class="flex gap-2">
<input type="text" class="flex-1 input-bg border rounded-lg px-4 py-2 text-white" placeholder="Filler">
<button type="button" onclick="this.parentElement.remove()" class="px-3 py-2 text-red-400 hover:text-red-300"></button>
</div>
{% endif %}
</div>
<button type="button" onclick="addListItem('filler_expressions', 'Filler')" class="mt-2 text-brand-highlight text-sm hover:underline">Hinzufügen</button>
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">Ending-Phrasen</label>
<div id="ending_phrases" class="space-y-2">
{% for phrase in profile_analysis.phrase_library.ending_phrases or [] %}
<div class="flex gap-2">
<input type="text" class="flex-1 input-bg border rounded-lg px-4 py-2 text-white" value="{{ phrase }}">
<button type="button" onclick="this.parentElement.remove()" class="px-3 py-2 text-red-400 hover:text-red-300"></button>
</div>
{% endfor %}
{% if not profile_analysis or not profile_analysis.phrase_library or not profile_analysis.phrase_library.ending_phrases %}
<div class="flex gap-2">
<input type="text" class="flex-1 input-bg border rounded-lg px-4 py-2 text-white" placeholder="Abschluss">
<button type="button" onclick="this.parentElement.remove()" class="px-3 py-2 text-red-400 hover:text-red-300"></button>
</div>
{% endif %}
</div>
<button type="button" onclick="addListItem('ending_phrases', 'Abschluss')" class="mt-2 text-brand-highlight text-sm hover:underline">Hinzufügen</button>
</div>
</div>
</div>
<div class="card-bg border rounded-2xl p-6">
<div class="flex items-center gap-3 mb-4">
<div class="w-9 h-9 rounded-lg bg-brand-highlight/15 flex items-center justify-center">
<svg class="w-4 h-4 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-2v13"/>
</svg>
</div>
<h2 class="text-lg font-semibold text-white">Ton-Analyse</h2>
</div>
<div class="grid md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-300 mb-1">Primärer Ton</label>
<input type="text" id="tone_primary" class="w-full input-bg border rounded-lg px-4 py-2 text-white"
value="{{ profile_analysis.tone_analysis.primary_tone if profile_analysis and profile_analysis.tone_analysis else '' }}">
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-1">Sekundäre Töne</label>
<div id="tone_secondary" class="space-y-2">
{% for phrase in profile_analysis.tone_analysis.secondary_tones or [] %}
<div class="flex gap-2">
<input type="text" class="flex-1 input-bg border rounded-lg px-4 py-2 text-white" value="{{ phrase }}">
<button type="button" onclick="this.parentElement.remove()" class="px-3 py-2 text-red-400 hover:text-red-300"></button>
</div>
{% endfor %}
{% if not profile_analysis or not profile_analysis.tone_analysis or not profile_analysis.tone_analysis.secondary_tones %}
<div class="flex gap-2">
<input type="text" class="flex-1 input-bg border rounded-lg px-4 py-2 text-white" placeholder="Sekundärer Ton">
<button type="button" onclick="this.parentElement.remove()" class="px-3 py-2 text-red-400 hover:text-red-300"></button>
</div>
{% endif %}
</div>
<button type="button" onclick="addListItem('tone_secondary', 'Sekundärer Ton')" class="mt-2 text-brand-highlight text-sm hover:underline">Hinzufügen</button>
</div>
</div>
</div>
<div class="card-bg border rounded-2xl p-6">
<div class="flex items-center gap-3 mb-4">
<div class="w-9 h-9 rounded-lg bg-brand-highlight/15 flex items-center justify-center">
<svg class="w-4 h-4 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a4 4 0 00-4-4h-1M9 20H4v-2a4 4 0 014-4h1m4-4a4 4 0 11-8 0 4 4 0 018 0zm6 2a3 3 0 100-6 3 3 0 000 6z"/>
</svg>
</div>
<h2 class="text-lg font-semibold text-white">Zielgruppen-Insights</h2>
</div>
<div class="grid md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-300 mb-1">Branche / Kontext</label>
<input type="text" id="audience_industry_context" class="w-full input-bg border rounded-lg px-4 py-2 text-white"
value="{{ profile_analysis.audience_insights.industry_context if profile_analysis and profile_analysis.audience_insights else '' }}">
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-1">Zielgruppe</label>
<input type="text" id="audience_target" class="w-full input-bg border rounded-lg px-4 py-2 text-white"
value="{{ profile_analysis.audience_insights.target_audience if profile_analysis and profile_analysis.audience_insights else '' }}">
</div>
</div>
<div class="mt-4">
<label class="block text-sm font-medium text-gray-300 mb-2">Pain Points</label>
<div id="audience_pain_points" class="space-y-2">
{% for phrase in profile_analysis.audience_insights.pain_points_addressed or [] %}
<div class="flex gap-2">
<input type="text" class="flex-1 input-bg border rounded-lg px-4 py-2 text-white" value="{{ phrase }}">
<button type="button" onclick="this.parentElement.remove()" class="px-3 py-2 text-red-400 hover:text-red-300"></button>
</div>
{% endfor %}
{% if not profile_analysis or not profile_analysis.audience_insights or not profile_analysis.audience_insights.pain_points_addressed %}
<div class="flex gap-2">
<input type="text" class="flex-1 input-bg border rounded-lg px-4 py-2 text-white" placeholder="Pain Point">
<button type="button" onclick="this.parentElement.remove()" class="px-3 py-2 text-red-400 hover:text-red-300"></button>
</div>
{% endif %}
</div>
<button type="button" onclick="addListItem('audience_pain_points', 'Pain Point')" class="mt-2 text-brand-highlight text-sm hover:underline">Hinzufügen</button>
</div>
</div>
<div class="card-bg border rounded-2xl p-6">
<div class="flex items-center gap-3 mb-4">
<div class="w-9 h-9 rounded-lg bg-brand-highlight/15 flex items-center justify-center">
<svg class="w-4 h-4 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4h7v7H4V4zm9 0h7v7h-7V4zM4 13h7v7H4v-7zm9 0h7v7h-7v-7z"/>
</svg>
</div>
<h2 class="text-lg font-semibold text-white">Topic Patterns</h2>
</div>
<div class="grid md:grid-cols-3 gap-4">
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">Hauptthemen</label>
<div id="topic_main" class="space-y-2">
{% for phrase in profile_analysis.topic_patterns.main_topics or [] %}
<div class="flex gap-2">
<input type="text" class="flex-1 input-bg border rounded-lg px-4 py-2 text-white" value="{{ phrase }}">
<button type="button" onclick="this.parentElement.remove()" class="px-3 py-2 text-red-400 hover:text-red-300"></button>
</div>
{% endfor %}
{% if not profile_analysis or not profile_analysis.topic_patterns or not profile_analysis.topic_patterns.main_topics %}
<div class="flex gap-2">
<input type="text" class="flex-1 input-bg border rounded-lg px-4 py-2 text-white" placeholder="Thema">
<button type="button" onclick="this.parentElement.remove()" class="px-3 py-2 text-red-400 hover:text-red-300"></button>
</div>
{% endif %}
</div>
<button type="button" onclick="addListItem('topic_main', 'Thema')" class="mt-2 text-brand-highlight text-sm hover:underline">Hinzufügen</button>
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">Wiederkehrende Themen</label>
<div id="topic_recurring" class="space-y-2">
{% for phrase in profile_analysis.topic_patterns.recurring_themes or [] %}
<div class="flex gap-2">
<input type="text" class="flex-1 input-bg border rounded-lg px-4 py-2 text-white" value="{{ phrase }}">
<button type="button" onclick="this.parentElement.remove()" class="px-3 py-2 text-red-400 hover:text-red-300"></button>
</div>
{% endfor %}
{% if not profile_analysis or not profile_analysis.topic_patterns or not profile_analysis.topic_patterns.recurring_themes %}
<div class="flex gap-2">
<input type="text" class="flex-1 input-bg border rounded-lg px-4 py-2 text-white" placeholder="Thema">
<button type="button" onclick="this.parentElement.remove()" class="px-3 py-2 text-red-400 hover:text-red-300"></button>
</div>
{% endif %}
</div>
<button type="button" onclick="addListItem('topic_recurring', 'Thema')" class="mt-2 text-brand-highlight text-sm hover:underline">Hinzufügen</button>
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">Formate</label>
<div id="topic_formats" class="space-y-2">
{% for phrase in profile_analysis.topic_patterns.content_formats or [] %}
<div class="flex gap-2">
<input type="text" class="flex-1 input-bg border rounded-lg px-4 py-2 text-white" value="{{ phrase }}">
<button type="button" onclick="this.parentElement.remove()" class="px-3 py-2 text-red-400 hover:text-red-300"></button>
</div>
{% endfor %}
{% if not profile_analysis or not profile_analysis.topic_patterns or not profile_analysis.topic_patterns.content_formats %}
<div class="flex gap-2">
<input type="text" class="flex-1 input-bg border rounded-lg px-4 py-2 text-white" placeholder="Format">
<button type="button" onclick="this.parentElement.remove()" class="px-3 py-2 text-red-400 hover:text-red-300"></button>
</div>
{% endif %}
</div>
<button type="button" onclick="addListItem('topic_formats', 'Format')" class="mt-2 text-brand-highlight text-sm hover:underline">Hinzufügen</button>
</div>
</div>
</div>
<details class="card-bg border rounded-2xl p-6">
<summary class="flex items-center justify-between cursor-pointer text-white font-semibold">
<span class="flex items-center gap-3">
<span class="w-9 h-9 rounded-lg bg-brand-highlight/15 flex items-center justify-center">
<svg class="w-4 h-4 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 6h8M8 12h8m-5 6h5"/>
</svg>
</span>
Zusatzdaten
</span>
<span class="text-xs text-gray-400">Optional</span>
</summary>
<div class="mt-4 space-y-3">
<div class="text-sm text-gray-400">
Erweiterte Felder als strukturierte Listen. Werte lassen sich hinzufügen oder entfernen.
</div>
<div id="extras_fields" class="space-y-4"></div>
<button type="button" onclick="addExtrasField()" class="text-brand-highlight text-sm hover:underline">
Zusatzfeld hinzufügen
</button>
</div>
</details>
<div class="flex justify-end">
<button type="submit" class="btn-primary font-medium py-3 px-8 rounded-lg transition-colors">
Analyse speichern
</button>
</div>
</form>
</div>
<script>
function addListItem(containerId, placeholderText) {
const container = document.getElementById(containerId);
if (!container) return;
const div = document.createElement('div');
div.className = 'flex gap-2';
div.innerHTML = `
<input type="text" class="flex-1 input-bg border rounded-lg px-4 py-2 text-white" placeholder="${placeholderText}">
<button type="button" onclick="this.parentElement.remove()" class="px-3 py-2 text-red-400 hover:text-red-300">✕</button>
`;
container.appendChild(div);
}
function collectList(containerId) {
const container = document.getElementById(containerId);
if (!container) return [];
return Array.from(container.querySelectorAll('input'))
.map(input => input.value.trim())
.filter(Boolean);
}
function toNumber(value) {
if (value === '' || value === null || value === undefined) return null;
const num = Number(value);
return Number.isNaN(num) ? null : num;
}
function parseOriginal() {
const raw = document.getElementById('analysis_original_json')?.value || '';
try {
return raw ? JSON.parse(raw) : {};
} catch (e) {
return {};
}
}
function buildAnalysisJson() {
const base = parseOriginal();
const writingStyle = {
tone: document.getElementById('writing_tone')?.value.trim() || '',
perspective: document.getElementById('writing_perspective')?.value.trim() || '',
form_of_address: document.getElementById('writing_form_of_address')?.value.trim() || '',
sentence_dynamics: document.getElementById('writing_sentence_dynamics')?.value.trim() || '',
average_post_length: document.getElementById('writing_average_post_length')?.value.trim() || '',
average_word_count: toNumber(document.getElementById('writing_average_word_count')?.value)
};
const linguisticFingerprint = {
energy_level: toNumber(document.getElementById('linguistic_energy_level')?.value),
formality_level: toNumber(document.getElementById('linguistic_formality_level')?.value),
shouting_usage: document.getElementById('linguistic_shouting_usage')?.value.trim() || '',
punctuation_patterns: document.getElementById('linguistic_punctuation_patterns')?.value.trim() || '',
signature_phrases: collectList('signature_phrases'),
narrative_anchors: collectList('narrative_anchors')
};
const phraseLibrary = {
hook_phrases: collectList('hook_phrases'),
transition_phrases: collectList('transition_phrases'),
emotional_expressions: collectList('emotional_expressions'),
cta_phrases: collectList('cta_phrases'),
filler_expressions: collectList('filler_expressions'),
ending_phrases: collectList('ending_phrases')
};
const toneAnalysis = {
primary_tone: document.getElementById('tone_primary')?.value.trim() || '',
secondary_tones: collectList('tone_secondary')
};
const audienceInsights = {
industry_context: document.getElementById('audience_industry_context')?.value.trim() || '',
target_audience: document.getElementById('audience_target')?.value.trim() || '',
pain_points_addressed: collectList('audience_pain_points')
};
const topicPatterns = {
main_topics: collectList('topic_main'),
recurring_themes: collectList('topic_recurring'),
content_formats: collectList('topic_formats')
};
base.writing_style = writingStyle;
base.linguistic_fingerprint = linguisticFingerprint;
base.phrase_library = phraseLibrary;
base.tone_analysis = toneAnalysis;
base.audience_insights = audienceInsights;
base.topic_patterns = topicPatterns;
const extras = collectExtras();
Object.keys(extras).forEach((key) => {
base[key] = extras[key];
});
return base;
}
const form = document.getElementById('strategyForm');
if (form) {
form.addEventListener('submit', (event) => {
const built = buildAnalysisJson();
document.getElementById('analysis_json').value = JSON.stringify(built, null, 2);
});
}
function addExtrasField(name = '', values = []) {
const container = document.getElementById('extras_fields');
if (!container) return;
const field = document.createElement('div');
field.className = 'bg-brand-bg/40 border border-brand-bg-light rounded-xl p-4 space-y-3';
field.innerHTML = `
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-3">
<div class="flex-1">
<label class="block text-xs uppercase tracking-wide text-gray-400 mb-1">Feldname</label>
<input type="text" class="extras-name w-full input-bg border rounded-lg px-3 py-2 text-white text-sm" placeholder="z.B. Produkt-Features" value="${name || ''}">
</div>
<button type="button" class="text-red-400 hover:text-red-300 text-sm" onclick="this.closest('.extras-field').remove()">Feld entfernen</button>
</div>
<div class="extras-values space-y-2"></div>
<button type="button" class="text-brand-highlight text-sm hover:underline add-extra-value">Wert hinzufügen</button>
`;
field.classList.add('extras-field');
container.appendChild(field);
const valuesContainer = field.querySelector('.extras-values');
const addValueBtn = field.querySelector('.add-extra-value');
addValueBtn.addEventListener('click', () => addExtrasValue(valuesContainer, ''));
if (!values || values.length === 0) {
addExtrasValue(valuesContainer, '');
} else {
values.forEach((value) => addExtrasValue(valuesContainer, value));
}
}
function addExtrasValue(container, value) {
const row = document.createElement('div');
row.className = 'flex gap-2 items-start';
row.innerHTML = `
<input type="text" class="flex-1 input-bg border rounded-lg px-3 py-2 text-white text-sm" placeholder="Wert" value="${value || ''}">
<button type="button" class="px-3 py-2 text-red-400 hover:text-red-300" onclick="this.parentElement.remove()">✕</button>
`;
container.appendChild(row);
}
function collectExtras() {
const container = document.getElementById('extras_fields');
if (!container) return {};
const extras = {};
container.querySelectorAll('.extras-field').forEach((field) => {
const nameInput = field.querySelector('.extras-name');
const name = nameInput ? nameInput.value.trim() : '';
if (!name) return;
const values = Array.from(field.querySelectorAll('.extras-values input'))
.map(input => input.value.trim())
.filter(Boolean);
if (values.length) {
extras[name] = values;
}
});
return extras;
}
function normalizeExtrasValue(value) {
if (Array.isArray(value)) return value.map(item => String(item));
if (value === null || value === undefined) return [];
if (typeof value === 'object') {
if (Array.isArray(value.typical_emojis)) {
return value.typical_emojis.map(item => String(item));
}
if (typeof value.typical_emojis === 'string' && value.typical_emojis.trim()) {
return value.typical_emojis.split(/\s+/).map(item => item.trim()).filter(Boolean);
}
return Object.entries(value).map(([key, val]) => `${key}: ${val}`);
}
return [String(value)];
}
function collectAllStrings(obj, acc) {
if (obj === null || obj === undefined) return acc;
if (typeof obj === 'string') {
acc.push(obj);
return acc;
}
if (Array.isArray(obj)) {
obj.forEach(item => collectAllStrings(item, acc));
return acc;
}
if (typeof obj === 'object') {
Object.values(obj).forEach(value => collectAllStrings(value, acc));
}
return acc;
}
function extractEmojis(text) {
if (!text) return [];
const matches = text.match(/\p{Extended_Pictographic}/gu);
return matches ? matches : [];
}
function getEmojiSummary(base) {
const strings = collectAllStrings(base, []);
const all = strings.flatMap(extractEmojis);
const uniq = Array.from(new Set(all));
return uniq;
}
function extractExtras() {
const base = parseOriginal();
const emojiSummary = getEmojiSummary(base);
const extras = {};
Object.keys(base || {}).forEach((key) => {
if (!['writing_style','linguistic_fingerprint','phrase_library','tone_analysis','audience_insights','topic_patterns'].includes(key)) {
extras[key] = base[key];
}
});
const entries = Object.entries(extras);
if (!entries.length) {
addExtrasField();
return;
}
entries.forEach(([key, value]) => {
if (key === 'visual_patterns' && Array.isArray(value)) {
const patched = value.map((item) => {
const text = String(item);
if (text.includes('emoji_usage') && text.includes('[object Object]')) {
const emojisText = emojiSummary.length ? emojiSummary.join(' ') : '';
return emojisText ? `emoji_usage: ${emojisText}` : '';
}
return text.includes('[object Object]') ? '' : text;
}).filter(Boolean);
if (patched.length) {
addExtrasField(key, patched);
}
return;
}
const normalized = normalizeExtrasValue(value).filter((entry) => !String(entry).includes('[object Object]'));
if (normalized.length) {
addExtrasField(key, normalized);
}
});
}
extractExtras();
</script>
{% endblock %} {% endblock %}

View File

@@ -44,7 +44,9 @@ from src.services.storage_service import storage
from src.services.link_extractor import LinkExtractor, LinkExtractionError from src.services.link_extractor import LinkExtractor, LinkExtractionError
from src.services.file_extractor import FileExtractor, FileExtractionError from src.services.file_extractor import FileExtractor, FileExtractionError
from src.agents.link_topic_builder import LinkTopicBuilderAgent from src.agents.link_topic_builder import LinkTopicBuilderAgent
from src.agents.strategy_importer import StrategyImporterAgent
from src.services.post_insights_service import compute_post_insights, refresh_post_insights_for_account from src.services.post_insights_service import compute_post_insights, refresh_post_insights_for_account
from src.services.insights_summary_service import generate_insights_summary
# Router for user frontend # Router for user frontend
user_router = APIRouter(tags=["user"]) user_router = APIRouter(tags=["user"])
@@ -3036,14 +3038,34 @@ async def refresh_post_insights(request: Request):
metadata = profile.metadata or {} metadata = profile.metadata or {}
today = datetime.now(timezone.utc).date().isoformat() today = datetime.now(timezone.utc).date().isoformat()
last_refresh = metadata.get("post_insights_manual_refresh_date") last_refresh = metadata.get("post_insights_manual_refresh_date")
if last_refresh == today: if settings.insights_manual_refresh_limit_enabled and last_refresh == today:
raise HTTPException(status_code=429, detail="Manual refresh already used today") raise HTTPException(status_code=429, detail="Manual refresh already used today")
await refresh_post_insights_for_account(db, linkedin_account) # Mark refresh immediately to avoid concurrent spam
metadata["post_insights_manual_refresh_date"] = today metadata["post_insights_manual_refresh_date"] = today
await db.update_profile(user_id, {"metadata": metadata}) await db.update_profile(user_id, {"metadata": metadata})
return {"success": True, "refreshed_at": today} async def _bg_refresh_and_summary():
try:
await refresh_post_insights_for_account(db, linkedin_account)
from datetime import date, timedelta
since = (date.today() - timedelta(days=90)).isoformat()
insights_posts = await db.get_post_insights_posts(user_id)
insights_daily = await db.get_post_insights_daily(user_id, since_date=since)
post_insights = compute_post_insights(insights_posts, insights_daily)
if post_insights and post_insights.get("has_data"):
summary = await asyncio.to_thread(generate_insights_summary, post_insights)
if summary:
metadata["post_insights_summary"] = summary
metadata["post_insights_summary_date"] = today
await db.update_profile(user_id, {"metadata": metadata})
except Exception as e:
logger.warning(f"Manual insights refresh failed: {e}")
asyncio.create_task(_bg_refresh_and_summary())
return {"success": True, "refreshed_at": today, "queued": True}
except HTTPException: except HTTPException:
raise raise
@@ -3122,6 +3144,63 @@ async def company_strategy_submit(request: Request):
}) })
@user_router.post("/company/strategy/import")
async def company_strategy_import(request: Request):
"""Import company strategy from a PDF document."""
session = require_user_session(request)
if not session:
raise HTTPException(status_code=401, detail="Not authenticated")
if session.account_type != "company" or not session.company_id:
raise HTTPException(status_code=403, detail="Company account required")
# Check token limit for companies/employees
can_create, error_msg, _, _ = await db.check_company_token_limit(UUID(session.company_id))
if not can_create:
raise HTTPException(status_code=429, detail=error_msg)
try:
form = await request.form()
upload: UploadFile = form.get("file") # type: ignore[assignment]
if not upload:
raise HTTPException(status_code=400, detail="Keine Datei hochgeladen.")
filename = upload.filename or ""
ext = Path(filename).suffix.lower()
if ext != ".pdf":
raise HTTPException(status_code=400, detail="Bitte eine PDF-Datei hochladen.")
file_bytes = await upload.read()
max_bytes = 10 * 1024 * 1024 # 10 MB
if len(file_bytes) > max_bytes:
raise HTTPException(status_code=400, detail="Datei ist zu groß (max 10 MB).")
extractor = FileExtractor()
try:
text = extractor.extract_text(file_bytes, filename)
except FileExtractionError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if len(text) > 50000:
text = text[:50000]
company = await db.get_company(UUID(session.company_id))
importer = StrategyImporterAgent()
importer.set_tracking_context(
operation="company_strategy_import",
user_id=session.user_id,
company_id=session.company_id
)
strategy = await importer.process(text, company_name=company.name if company else None)
return JSONResponse({"success": True, "strategy": strategy})
except HTTPException:
raise
except Exception as e:
logger.exception(f"Company strategy import failed: {e}")
raise HTTPException(status_code=500, detail=str(e))
@user_router.get("/company/accounts", response_class=HTMLResponse) @user_router.get("/company/accounts", response_class=HTMLResponse)
async def company_accounts_page(request: Request): async def company_accounts_page(request: Request):
"""Company employee management page.""" """Company employee management page."""
@@ -3589,8 +3668,8 @@ async def company_manage_post_types(request: Request, employee_id: str = None):
# ==================== EMPLOYEE ROUTES ==================== # ==================== EMPLOYEE ROUTES ====================
@user_router.get("/employee/strategy", response_class=HTMLResponse) @user_router.get("/employee/strategy", response_class=HTMLResponse)
async def employee_strategy_page(request: Request): async def employee_strategy_page(request: Request, success: bool = False):
"""Read-only company strategy view for employees.""" """Employee profile analysis (strategy) view."""
session = require_user_session(request) session = require_user_session(request)
if not session: if not session:
return RedirectResponse(url="/login", status_code=302) return RedirectResponse(url="/login", status_code=302)
@@ -3599,63 +3678,70 @@ async def employee_strategy_page(request: Request):
if session.account_type != "employee" or not session.company_id: if session.account_type != "employee" or not session.company_id:
return RedirectResponse(url="/", status_code=302) return RedirectResponse(url="/", status_code=302)
company = await db.get_company(UUID(session.company_id))
strategy = company.company_strategy if company else {}
user_id = UUID(session.user_id) user_id = UUID(session.user_id)
profile_picture = await get_user_avatar(session, user_id) profile_picture = await get_user_avatar(session, user_id)
profile_analysis = await db.get_profile_analysis(user_id)
analysis = profile_analysis.full_analysis if profile_analysis else {}
if not isinstance(analysis, dict):
analysis = {}
analysis_json = json.dumps(analysis, ensure_ascii=False, indent=2)
return templates.TemplateResponse("employee_strategy.html", { return templates.TemplateResponse("employee_strategy.html", {
"request": request, "request": request,
"page": "strategy", "page": "strategy",
"session": session, "session": session,
"company": company, "profile_analysis": analysis,
"strategy": strategy, "analysis_json": analysis_json,
"analysis_created_at": profile_analysis.created_at if profile_analysis else None,
"success": success,
"profile_picture": profile_picture "profile_picture": profile_picture
}) })
@user_router.get("/employee/insights", response_class=HTMLResponse) @user_router.post("/employee/strategy", response_class=HTMLResponse)
async def employee_insights_page(request: Request): async def employee_strategy_submit(request: Request):
"""Employee post insights page.""" """Save edited profile analysis for employees."""
session = require_user_session(request) session = require_user_session(request)
if not session: if not session:
return RedirectResponse(url="/login", status_code=302) return RedirectResponse(url="/login", status_code=302)
if session.account_type != "employee": if session.account_type != "employee" or not session.company_id:
return RedirectResponse(url="/", status_code=302) return RedirectResponse(url="/", status_code=302)
form = await request.form()
raw_json = (form.get("analysis_json") or "").strip()
try: try:
analysis = json.loads(raw_json) if raw_json else {}
if not isinstance(analysis, dict):
raise ValueError("JSON muss ein Objekt sein.")
from src.database.models import ProfileAnalysis
user_id = UUID(session.user_id)
analysis_record = ProfileAnalysis(
user_id=user_id,
writing_style=analysis.get("writing_style", {}) or {},
tone_analysis=analysis.get("tone_analysis", {}) or {},
topic_patterns=analysis.get("topic_patterns", {}) or {},
audience_insights=analysis.get("audience_insights", {}) or {},
full_analysis=analysis
)
await db.save_profile_analysis(analysis_record)
return RedirectResponse(url="/employee/strategy?success=true", status_code=302)
except Exception as e:
user_id = UUID(session.user_id) user_id = UUID(session.user_id)
profile_picture = await get_user_avatar(session, user_id) profile_picture = await get_user_avatar(session, user_id)
linkedin_account = await db.get_linkedin_account(user_id) return templates.TemplateResponse("employee_strategy.html", {
post_insights = {"has_data": False}
if linkedin_account:
try:
from datetime import date, timedelta
since = (date.today() - timedelta(days=90)).isoformat()
insights_posts = await db.get_post_insights_posts(user_id)
insights_daily = await db.get_post_insights_daily(user_id, since_date=since)
post_insights = compute_post_insights(insights_posts, insights_daily)
except Exception as e:
logger.error(f"Error computing post insights: {e}")
return templates.TemplateResponse("employee_insights.html", {
"request": request, "request": request,
"page": "insights", "page": "strategy",
"session": session, "session": session,
"profile_picture": profile_picture, "profile_analysis": {},
"linkedin_account": linkedin_account, "analysis_json": raw_json,
"post_insights": post_insights "analysis_created_at": None,
}) "error": f"Fehler beim Speichern: {e}",
except Exception as e: "profile_picture": profile_picture
logger.error(f"Error loading insights: {e}")
return templates.TemplateResponse("employee_insights.html", {
"request": request,
"page": "insights",
"session": session,
"error": str(e)
}) })