added strtegy import and amployee strategy
This commit is contained in:
@@ -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)
|
||||||
# ===========================================
|
# ===========================================
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
69
src/agents/strategy_importer.py
Normal file
69
src/agents/strategy_importer.py
Normal 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 [],
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
82
src/services/insights_summary_service.py
Normal file
82
src/services/insights_summary_service.py
Normal 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
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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!
|
||||||
@@ -200,6 +246,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 %}
|
|
||||||
@@ -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>
|
||||||
|
<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>
|
</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 %}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user