aktueller stand
This commit is contained in:
2
src/__init__.py
Normal file
2
src/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""LinkedIn Workflow System - Main package."""
|
||||
__version__ = "1.0.0"
|
||||
20
src/agents/__init__.py
Normal file
20
src/agents/__init__.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""AI Agents module."""
|
||||
from src.agents.base import BaseAgent
|
||||
from src.agents.profile_analyzer import ProfileAnalyzerAgent
|
||||
from src.agents.topic_extractor import TopicExtractorAgent
|
||||
from src.agents.researcher import ResearchAgent
|
||||
from src.agents.writer import WriterAgent
|
||||
from src.agents.critic import CriticAgent
|
||||
from src.agents.post_classifier import PostClassifierAgent
|
||||
from src.agents.post_type_analyzer import PostTypeAnalyzerAgent
|
||||
|
||||
__all__ = [
|
||||
"BaseAgent",
|
||||
"ProfileAnalyzerAgent",
|
||||
"TopicExtractorAgent",
|
||||
"ResearchAgent",
|
||||
"WriterAgent",
|
||||
"CriticAgent",
|
||||
"PostClassifierAgent",
|
||||
"PostTypeAnalyzerAgent",
|
||||
]
|
||||
120
src/agents/base.py
Normal file
120
src/agents/base.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""Base agent class."""
|
||||
import asyncio
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Dict, Optional
|
||||
from openai import OpenAI
|
||||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
from src.config import settings
|
||||
|
||||
|
||||
class BaseAgent(ABC):
|
||||
"""Base class for all AI agents."""
|
||||
|
||||
def __init__(self, name: str):
|
||||
"""
|
||||
Initialize base agent.
|
||||
|
||||
Args:
|
||||
name: Name of the agent
|
||||
"""
|
||||
self.name = name
|
||||
self.openai_client = OpenAI(api_key=settings.openai_api_key)
|
||||
logger.info(f"Initialized {name} agent")
|
||||
|
||||
@abstractmethod
|
||||
async def process(self, *args, **kwargs) -> Any:
|
||||
"""Process the agent's task."""
|
||||
pass
|
||||
|
||||
async def call_openai(
|
||||
self,
|
||||
system_prompt: str,
|
||||
user_prompt: str,
|
||||
model: str = "gpt-4o",
|
||||
temperature: float = 0.7,
|
||||
response_format: Optional[Dict[str, str]] = None
|
||||
) -> str:
|
||||
"""
|
||||
Call OpenAI API.
|
||||
|
||||
Args:
|
||||
system_prompt: System message
|
||||
user_prompt: User message
|
||||
model: Model to use
|
||||
temperature: Temperature for sampling
|
||||
response_format: Optional response format (e.g., {"type": "json_object"})
|
||||
|
||||
Returns:
|
||||
Assistant's response
|
||||
"""
|
||||
logger.info(f"[{self.name}] Calling OpenAI ({model})")
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt}
|
||||
]
|
||||
|
||||
kwargs = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"temperature": temperature
|
||||
}
|
||||
|
||||
if response_format:
|
||||
kwargs["response_format"] = response_format
|
||||
|
||||
# Run synchronous OpenAI call in thread pool to avoid blocking event loop
|
||||
response = await asyncio.to_thread(
|
||||
self.openai_client.chat.completions.create,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
result = response.choices[0].message.content
|
||||
logger.debug(f"[{self.name}] Received response (length: {len(result)})")
|
||||
|
||||
return result
|
||||
|
||||
async def call_perplexity(
|
||||
self,
|
||||
system_prompt: str,
|
||||
user_prompt: str,
|
||||
model: str = "sonar"
|
||||
) -> str:
|
||||
"""
|
||||
Call Perplexity API for research.
|
||||
|
||||
Args:
|
||||
system_prompt: System message
|
||||
user_prompt: User message
|
||||
model: Model to use
|
||||
|
||||
Returns:
|
||||
Assistant's response
|
||||
"""
|
||||
logger.info(f"[{self.name}] Calling Perplexity ({model})")
|
||||
|
||||
url = "https://api.perplexity.ai/chat/completions"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {settings.perplexity_api_key}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt}
|
||||
]
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(url, json=payload, headers=headers, timeout=60.0)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
content = result["choices"][0]["message"]["content"]
|
||||
logger.debug(f"[{self.name}] Received Perplexity response (length: {len(content)})")
|
||||
|
||||
return content
|
||||
276
src/agents/critic.py
Normal file
276
src/agents/critic.py
Normal file
@@ -0,0 +1,276 @@
|
||||
"""Critic agent for reviewing and improving LinkedIn posts."""
|
||||
import json
|
||||
from typing import Dict, Any, Optional, List
|
||||
from loguru import logger
|
||||
|
||||
from src.agents.base import BaseAgent
|
||||
|
||||
|
||||
class CriticAgent(BaseAgent):
|
||||
"""Agent for critically reviewing LinkedIn posts and suggesting improvements."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize critic agent."""
|
||||
super().__init__("Critic")
|
||||
|
||||
async def process(
|
||||
self,
|
||||
post: str,
|
||||
profile_analysis: Dict[str, Any],
|
||||
topic: Dict[str, Any],
|
||||
example_posts: Optional[List[str]] = None,
|
||||
iteration: int = 1,
|
||||
max_iterations: int = 3
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Review a LinkedIn post and provide feedback.
|
||||
|
||||
Args:
|
||||
post: The post to review
|
||||
profile_analysis: Profile analysis results
|
||||
topic: Topic information
|
||||
example_posts: Optional list of real posts to compare style against
|
||||
iteration: Current iteration number (1-based)
|
||||
max_iterations: Maximum number of iterations allowed
|
||||
|
||||
Returns:
|
||||
Dictionary with approval status and feedback
|
||||
"""
|
||||
logger.info(f"Reviewing post for quality and authenticity (iteration {iteration}/{max_iterations})")
|
||||
|
||||
system_prompt = self._get_system_prompt(profile_analysis, example_posts, iteration, max_iterations)
|
||||
user_prompt = self._get_user_prompt(post, topic, iteration, max_iterations)
|
||||
|
||||
response = await self.call_openai(
|
||||
system_prompt=system_prompt,
|
||||
user_prompt=user_prompt,
|
||||
model="gpt-4o-mini",
|
||||
temperature=0.3,
|
||||
response_format={"type": "json_object"}
|
||||
)
|
||||
|
||||
# Parse response
|
||||
result = json.loads(response)
|
||||
|
||||
is_approved = result.get("approved", False)
|
||||
logger.info(f"Post {'APPROVED' if is_approved else 'NEEDS REVISION'}")
|
||||
|
||||
return result
|
||||
|
||||
def _get_system_prompt(self, profile_analysis: Dict[str, Any], example_posts: Optional[List[str]] = None, iteration: int = 1, max_iterations: int = 3) -> str:
|
||||
"""Get system prompt for critic - orientiert an bewährten n8n-Prompts."""
|
||||
writing_style = profile_analysis.get("writing_style", {})
|
||||
linguistic = profile_analysis.get("linguistic_fingerprint", {})
|
||||
tone_analysis = profile_analysis.get("tone_analysis", {})
|
||||
phrase_library = profile_analysis.get("phrase_library", {})
|
||||
structure_templates = profile_analysis.get("structure_templates", {})
|
||||
|
||||
# Build example posts section for style comparison
|
||||
examples_section = ""
|
||||
if example_posts and len(example_posts) > 0:
|
||||
examples_section = "\n\nECHTE POSTS DER PERSON (VERGLEICHE DEN STIL!):\n"
|
||||
for i, post in enumerate(example_posts, 1):
|
||||
post_text = post[:1200] + "..." if len(post) > 1200 else post
|
||||
examples_section += f"\n--- Echtes Beispiel {i} ---\n{post_text}\n"
|
||||
examples_section += "--- Ende Beispiele ---\n"
|
||||
|
||||
# Safe extraction of signature phrases
|
||||
sig_phrases = linguistic.get('signature_phrases', [])
|
||||
sig_phrases_str = ', '.join(sig_phrases) if sig_phrases else 'Keine spezifischen'
|
||||
|
||||
# Extract phrase library for style matching
|
||||
hook_phrases = phrase_library.get('hook_phrases', [])
|
||||
emotional_expressions = phrase_library.get('emotional_expressions', [])
|
||||
cta_phrases = phrase_library.get('cta_phrases', [])
|
||||
|
||||
# Extract structure info
|
||||
primary_structure = structure_templates.get('primary_structure', 'Hook → Body → CTA')
|
||||
|
||||
# Iteration-aware guidance
|
||||
iteration_guidance = ""
|
||||
if iteration == 1:
|
||||
iteration_guidance = """
|
||||
ERSTE ITERATION - Fokus auf die WICHTIGSTEN Verbesserungen:
|
||||
- Konzentriere dich auf maximal 2-3 kritische Punkte
|
||||
- Gib SEHR SPEZIFISCHE Änderungsanweisungen
|
||||
- Kleine Stilnuancen können in späteren Iterationen optimiert werden
|
||||
- Erwarteter Score-Bereich: 70-85 (selten höher beim ersten Entwurf)"""
|
||||
elif iteration == max_iterations:
|
||||
iteration_guidance = """
|
||||
LETZTE ITERATION - Faire Endbewertung:
|
||||
- Der Post wurde bereits überarbeitet - würdige die Verbesserungen!
|
||||
- Prüfe: Hat der Writer die vorherigen Kritikpunkte umgesetzt?
|
||||
- Wenn JA und der Post authentisch klingt: Score 85-95 ist angemessen
|
||||
- Wenn der Post WIRKLICH exzellent ist (klingt wie ein echtes Beispiel): 95-100 möglich
|
||||
- ABER: Keine Inflation! Nur 90+ wenn es wirklich verdient ist
|
||||
- Kleine Imperfektionen sind OK bei 85-89, nicht bei 90+"""
|
||||
else:
|
||||
iteration_guidance = f"""
|
||||
ITERATION {iteration}/{max_iterations} - Fortschritt anerkennen:
|
||||
- Prüfe ob vorherige Kritikpunkte umgesetzt wurden
|
||||
- Wenn Verbesserungen sichtbar: Score sollte steigen
|
||||
- Fokussiere auf verbleibende Verbesserungen
|
||||
- Erwarteter Score-Bereich: 75-90 (wenn erste Kritik gut umgesetzt)"""
|
||||
|
||||
return f"""ROLLE: Du bist ein präziser Chefredakteur für Personal Branding. Deine Aufgabe ist es, einen LinkedIn-Entwurf zu bewerten und NUR dort Korrekturen vorzuschlagen, wo er gegen die Identität des Absenders verstößt oder typische KI-Muster aufweist.
|
||||
{examples_section}
|
||||
{iteration_guidance}
|
||||
|
||||
REFERENZ-PROFIL (Der Maßstab):
|
||||
|
||||
Branche: {profile_analysis.get('audience_insights', {}).get('industry_context', 'Business')}
|
||||
Perspektive: {writing_style.get('perspective', 'Ich-Perspektive')}
|
||||
Ansprache: {writing_style.get('form_of_address', 'Du/Euch')}
|
||||
Energie-Level: {linguistic.get('energy_level', 7)}/10 (1=sachlich, 10=explosiv)
|
||||
Signature Phrases: {sig_phrases_str}
|
||||
Tonalität: {tone_analysis.get('primary_tone', 'Professionell')}
|
||||
Erwartete Struktur: {primary_structure}
|
||||
|
||||
PHRASEN-REFERENZ (Der Post sollte ÄHNLICHE Formulierungen nutzen - nicht identisch, aber im gleichen Stil):
|
||||
- Hook-Stil Beispiele: {', '.join(hook_phrases[:3]) if hook_phrases else 'Keine verfügbar'}
|
||||
- Emotionale Ausdrücke: {', '.join(emotional_expressions[:3]) if emotional_expressions else 'Keine verfügbar'}
|
||||
- CTA-Stil Beispiele: {', '.join(cta_phrases[:2]) if cta_phrases else 'Keine verfügbar'}
|
||||
|
||||
|
||||
CHIRURGISCHE KORREKTUR-REGELN (Prüfe diese Punkte!):
|
||||
|
||||
1. SATZBAU-OPTIMIERUNG:
|
||||
- Keine Gedankenstriche (–) zur Satzverbindung - diese wirken zu konstruiert
|
||||
- Wenn Gedankenstriche gefunden werden: Vorschlagen, durch Kommas, Punkte oder Konjunktionen zu ersetzen
|
||||
- Zwei eigenständige Sätze sind oft besser als ein verbundener
|
||||
|
||||
2. ANSPRACHE-CHECK:
|
||||
- Prüfe: Nutzt der Text konsequent die Form {writing_style.get('form_of_address', 'Du/Euch')}?
|
||||
- Falls inkonsistent (z.B. Sie statt Du oder umgekehrt): Als Fehler markieren
|
||||
|
||||
3. PERSPEKTIV-CHECK (Priorität 1!):
|
||||
- Wenn das Profil {writing_style.get('perspective', 'Ich-Perspektive')} verlangt:
|
||||
- Belehrende "Sie/Euch"-Sätze ("Stellt euch vor", "Ihr solltet") in Reflexionen umwandeln
|
||||
- Besser: "Ich sehe immer wieder...", "Ich frage mich oft..." statt direkter Handlungsaufforderungen
|
||||
|
||||
4. KI-MUSTER ERKENNEN:
|
||||
- "In der heutigen Zeit", "Tauchen Sie ein", "Es ist kein Geheimnis" = SOFORT bemängeln
|
||||
- "Stellen Sie sich vor", "Lassen Sie uns" = KI-typisch
|
||||
- Zu perfekte, glatte Formulierungen ohne Ecken und Kanten
|
||||
|
||||
5. ENERGIE-ABGLEICH:
|
||||
- Passt die Intensität zum Energie-Level ({linguistic.get('energy_level', 7)}/10)?
|
||||
- Zu lahm bei hohem Level oder zu überdreht bei niedrigem Level = Korrektur vorschlagen
|
||||
|
||||
6. UNICODE & FORMATIERUNG:
|
||||
- Prüfe den Hook: Ist Unicode-Fettung korrekt? (Umlaute ä, ö, ü, ß dürfen nicht zerstört sein)
|
||||
- Keine Markdown-Sterne (**) - LinkedIn unterstützt das nicht
|
||||
- Keine Trennlinien (---)
|
||||
|
||||
7. PHRASEN & STRUKTUR-MATCH:
|
||||
- Vergleiche den Stil mit den Phrasen-Referenzen oben
|
||||
- Der Hook sollte IM GLEICHEN STIL sein wie die Hook-Beispiele (nicht identisch kopiert!)
|
||||
- Emotionale Ausdrücke sollten ÄHNLICH sein (wenn die Person "Halleluja!" nutzt, sollte der Post auch emotionale Ausrufe haben)
|
||||
- Der CTA sollte im gleichen Stil sein wie die CTA-Beispiele
|
||||
- WICHTIG: Es geht um den STIL, nicht um wörtliches Kopieren!
|
||||
|
||||
|
||||
BEWERTUNGSKRITERIEN (100 Punkte total):
|
||||
|
||||
1. Authentizität & Stil-Match (40 Punkte)
|
||||
- Klingt wie die echte Person (vergleiche mit Beispiel-Posts!)
|
||||
- Keine KI-Muster erkennbar
|
||||
- Richtige Energie und Tonalität
|
||||
- Nutzt ÄHNLICHE Phrasen/Formulierungen wie in der Phrasen-Referenz (nicht identisch kopiert, aber im gleichen Stil!)
|
||||
- Hat die Person typische emotionale Ausdrücke? Sind welche im Post?
|
||||
|
||||
2. Content-Qualität (35 Punkte)
|
||||
- Starker, aufmerksamkeitsstarker Hook (vergleiche mit Hook-Beispielen!)
|
||||
- Klarer Mehrwert für die Zielgruppe
|
||||
- Gute Struktur und Lesefluss (folgt der erwarteten Struktur: {primary_structure})
|
||||
- Passender CTA (vergleiche mit CTA-Beispielen!)
|
||||
|
||||
3. Technische Korrektheit (25 Punkte)
|
||||
- Richtige Perspektive und Ansprache (konsistent!)
|
||||
- Angemessene Länge (~{writing_style.get('average_word_count', 300)} Wörter)
|
||||
- Korrekte Formatierung
|
||||
|
||||
|
||||
SCORE-KALIBRIERUNG (WICHTIG - lies das genau!):
|
||||
|
||||
**90-100 Punkte = Exzellent, direkt veröffentlichbar**
|
||||
- 100: Herausragend - Post klingt EXAKT wie die echte Person, perfekter Hook, null KI-Muster
|
||||
- 95-99: Exzellent - Kaum von echtem Post unterscheidbar, minimale Verbesserungsmöglichkeiten
|
||||
- 90-94: Sehr gut - Authentisch, professionell, kleine Stilnuancen könnten besser sein
|
||||
|
||||
**85-89 Punkte = Gut, veröffentlichungsreif**
|
||||
- Der Post funktioniert, erfüllt alle wichtigen Kriterien
|
||||
- Vielleicht 1-2 Formulierungen die noch besser sein könnten
|
||||
|
||||
**75-84 Punkte = Solide Basis, aber Verbesserungen nötig**
|
||||
- Grundstruktur stimmt, aber erkennbare Probleme
|
||||
- Entweder KI-Muster, Stil-Mismatch oder technische Fehler
|
||||
|
||||
**< 75 Punkte = Wesentliche Überarbeitung nötig**
|
||||
- Mehrere gravierende Probleme
|
||||
- Klingt nicht authentisch oder hat strukturelle Mängel
|
||||
|
||||
APPROVAL-SCHWELLEN:
|
||||
- >= 85 Punkte: APPROVED (veröffentlichungsreif)
|
||||
- 75-84 Punkte: Fast fertig, kleine Anpassungen
|
||||
- < 75 Punkte: Überarbeitung nötig
|
||||
|
||||
WICHTIG: Gib 90+ Punkte wenn der Post es VERDIENT - nicht aus Großzügigkeit!
|
||||
Ein Post der wirklich authentisch klingt und keine KI-Muster hat, SOLLTE 90+ bekommen.
|
||||
|
||||
|
||||
WICHTIG FÜR DEIN FEEDBACK:
|
||||
- Gib EXAKTE Formulierungsvorschläge: "Ändere 'X' zu 'Y'" (nicht "verbessere den Hook")
|
||||
- Maximal 3 konkrete Änderungen pro Iteration
|
||||
- Erkenne umgesetzte Verbesserungen an und erhöhe den Score entsprechend
|
||||
- Bei der letzten Iteration: Sei fair - gib 90+ wenn der Post es verdient, aber nicht aus Milde
|
||||
|
||||
Antworte als JSON."""
|
||||
|
||||
def _get_user_prompt(self, post: str, topic: Dict[str, Any], iteration: int = 1, max_iterations: int = 3) -> str:
|
||||
"""Get user prompt for critic."""
|
||||
iteration_note = ""
|
||||
if iteration > 1:
|
||||
iteration_note = f"\n**HINWEIS:** Dies ist Iteration {iteration} von {max_iterations}. Der Post wurde bereits überarbeitet.\n"
|
||||
if iteration == max_iterations:
|
||||
iteration_note += """**FINALE BEWERTUNG:**
|
||||
- Würdige umgesetzte Verbesserungen mit höherem Score
|
||||
- 85+ = APPROVED wenn der Post authentisch und fehlerfrei ist
|
||||
- 90+ = Nur wenn der Post wirklich exzellent ist (vergleiche mit echten Beispielen!)
|
||||
- Sei fair, nicht großzügig - Qualität bleibt der Maßstab.\n"""
|
||||
|
||||
return f"""Bewerte diesen LinkedIn-Post:
|
||||
{iteration_note}
|
||||
**THEMA:** {topic.get('title', 'Unknown')}
|
||||
|
||||
**POST:**
|
||||
{post}
|
||||
|
||||
---
|
||||
|
||||
Antworte im JSON-Format:
|
||||
|
||||
{{
|
||||
"approved": true/false,
|
||||
"overall_score": 0-100,
|
||||
"scores": {{
|
||||
"authenticity_and_style": 0-40,
|
||||
"content_quality": 0-35,
|
||||
"technical_execution": 0-25
|
||||
}},
|
||||
"strengths": ["Stärke 1", "Stärke 2"],
|
||||
"improvements": ["Verbesserung 1", "Verbesserung 2"],
|
||||
"feedback": "Kurze Zusammenfassung",
|
||||
"specific_changes": [
|
||||
{{
|
||||
"original": "Exakter Text aus dem Post der geändert werden soll",
|
||||
"replacement": "Der neue vorgeschlagene Text",
|
||||
"reason": "Warum diese Änderung"
|
||||
}}
|
||||
]
|
||||
}}
|
||||
|
||||
WICHTIG bei specific_changes:
|
||||
- Gib EXAKTE Textstellen an die geändert werden sollen
|
||||
- Maximal 3 Changes pro Iteration
|
||||
- Der "original" Text muss EXAKT im Post vorkommen"""
|
||||
279
src/agents/post_classifier.py
Normal file
279
src/agents/post_classifier.py
Normal file
@@ -0,0 +1,279 @@
|
||||
"""Post classifier agent for categorizing LinkedIn posts into post types."""
|
||||
import json
|
||||
import re
|
||||
from typing import Dict, Any, List, Optional, Tuple
|
||||
from uuid import UUID
|
||||
from loguru import logger
|
||||
|
||||
from src.agents.base import BaseAgent
|
||||
from src.database.models import LinkedInPost, PostType
|
||||
|
||||
|
||||
class PostClassifierAgent(BaseAgent):
|
||||
"""Agent for classifying LinkedIn posts into defined post types."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize post classifier agent."""
|
||||
super().__init__("PostClassifier")
|
||||
|
||||
async def process(
|
||||
self,
|
||||
posts: List[LinkedInPost],
|
||||
post_types: List[PostType]
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Classify posts into post types.
|
||||
|
||||
Uses a two-phase approach:
|
||||
1. Hashtag matching (fast, deterministic)
|
||||
2. Semantic matching via LLM (for posts without hashtag match)
|
||||
|
||||
Args:
|
||||
posts: List of posts to classify
|
||||
post_types: List of available post types
|
||||
|
||||
Returns:
|
||||
List of classification results with post_id, post_type_id, method, confidence
|
||||
"""
|
||||
if not posts or not post_types:
|
||||
logger.warning("No posts or post types to classify")
|
||||
return []
|
||||
|
||||
logger.info(f"Classifying {len(posts)} posts into {len(post_types)} post types")
|
||||
|
||||
classifications = []
|
||||
posts_needing_semantic = []
|
||||
|
||||
# Phase 1: Hashtag matching
|
||||
for post in posts:
|
||||
result = self._match_by_hashtags(post, post_types)
|
||||
if result:
|
||||
classifications.append(result)
|
||||
else:
|
||||
posts_needing_semantic.append(post)
|
||||
|
||||
logger.info(f"Hashtag matching: {len(classifications)} matched, {len(posts_needing_semantic)} need semantic")
|
||||
|
||||
# Phase 2: Semantic matching for remaining posts
|
||||
if posts_needing_semantic:
|
||||
semantic_results = await self._match_semantically(posts_needing_semantic, post_types)
|
||||
classifications.extend(semantic_results)
|
||||
|
||||
logger.info(f"Classification complete: {len(classifications)} total classifications")
|
||||
return classifications
|
||||
|
||||
def _extract_hashtags(self, text: str) -> List[str]:
|
||||
"""Extract hashtags from post text (lowercase for matching)."""
|
||||
hashtags = re.findall(r'#(\w+)', text)
|
||||
return [h.lower() for h in hashtags]
|
||||
|
||||
def _match_by_hashtags(
|
||||
self,
|
||||
post: LinkedInPost,
|
||||
post_types: List[PostType]
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Try to match post to a post type by hashtags.
|
||||
|
||||
Args:
|
||||
post: The post to classify
|
||||
post_types: Available post types
|
||||
|
||||
Returns:
|
||||
Classification dict or None if no match
|
||||
"""
|
||||
post_hashtags = set(self._extract_hashtags(post.post_text))
|
||||
|
||||
if not post_hashtags:
|
||||
return None
|
||||
|
||||
best_match = None
|
||||
best_match_count = 0
|
||||
|
||||
for pt in post_types:
|
||||
if not pt.identifying_hashtags:
|
||||
continue
|
||||
|
||||
# Convert post type hashtags to lowercase for comparison
|
||||
pt_hashtags = set(h.lower().lstrip('#') for h in pt.identifying_hashtags)
|
||||
|
||||
# Count matching hashtags
|
||||
matches = post_hashtags.intersection(pt_hashtags)
|
||||
|
||||
if matches and len(matches) > best_match_count:
|
||||
best_match = pt
|
||||
best_match_count = len(matches)
|
||||
|
||||
if best_match:
|
||||
# Confidence based on how many hashtags matched
|
||||
confidence = min(1.0, best_match_count * 0.25 + 0.5)
|
||||
return {
|
||||
"post_id": post.id,
|
||||
"post_type_id": best_match.id,
|
||||
"classification_method": "hashtag",
|
||||
"classification_confidence": confidence
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
async def _match_semantically(
|
||||
self,
|
||||
posts: List[LinkedInPost],
|
||||
post_types: List[PostType]
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Match posts to post types using semantic analysis via LLM.
|
||||
|
||||
Args:
|
||||
posts: Posts to classify
|
||||
post_types: Available post types
|
||||
|
||||
Returns:
|
||||
List of classification results
|
||||
"""
|
||||
if not posts:
|
||||
return []
|
||||
|
||||
# Build post type descriptions for the LLM
|
||||
type_descriptions = []
|
||||
for pt in post_types:
|
||||
desc = f"- **{pt.name}** (ID: {pt.id})"
|
||||
if pt.description:
|
||||
desc += f": {pt.description}"
|
||||
if pt.identifying_keywords:
|
||||
desc += f"\n Keywords: {', '.join(pt.identifying_keywords[:10])}"
|
||||
if pt.semantic_properties:
|
||||
props = pt.semantic_properties
|
||||
if props.get("purpose"):
|
||||
desc += f"\n Purpose: {props['purpose']}"
|
||||
if props.get("typical_tone"):
|
||||
desc += f"\n Tone: {props['typical_tone']}"
|
||||
type_descriptions.append(desc)
|
||||
|
||||
type_descriptions_text = "\n".join(type_descriptions)
|
||||
|
||||
# Process in batches for efficiency
|
||||
batch_size = 10
|
||||
results = []
|
||||
|
||||
for i in range(0, len(posts), batch_size):
|
||||
batch = posts[i:i + batch_size]
|
||||
batch_results = await self._classify_batch(batch, post_types, type_descriptions_text)
|
||||
results.extend(batch_results)
|
||||
|
||||
return results
|
||||
|
||||
async def _classify_batch(
|
||||
self,
|
||||
posts: List[LinkedInPost],
|
||||
post_types: List[PostType],
|
||||
type_descriptions: str
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Classify a batch of posts using LLM."""
|
||||
# Build post list for prompt
|
||||
posts_list = []
|
||||
for i, post in enumerate(posts):
|
||||
post_preview = post.post_text[:500] + "..." if len(post.post_text) > 500 else post.post_text
|
||||
posts_list.append(f"[Post {i + 1}] (ID: {post.id})\n{post_preview}")
|
||||
|
||||
posts_text = "\n\n".join(posts_list)
|
||||
|
||||
# Build valid type IDs for validation
|
||||
valid_type_ids = {str(pt.id) for pt in post_types}
|
||||
valid_type_ids.add("null") # Allow unclassified
|
||||
|
||||
system_prompt = """Du bist ein Content-Analyst, der LinkedIn-Posts in vordefinierte Kategorien einordnet.
|
||||
|
||||
Analysiere jeden Post und ordne ihn dem passendsten Post-Typ zu.
|
||||
Wenn kein Typ wirklich passt, gib "null" als post_type_id zurück.
|
||||
|
||||
Bewerte die Zuordnung mit einer Confidence zwischen 0.3 und 1.0:
|
||||
- 0.9-1.0: Sehr sicher, Post passt perfekt zum Typ
|
||||
- 0.7-0.9: Gute Übereinstimmung
|
||||
- 0.5-0.7: Moderate Übereinstimmung
|
||||
- 0.3-0.5: Schwache Übereinstimmung, aber beste verfügbare Option
|
||||
|
||||
Antworte im JSON-Format."""
|
||||
|
||||
user_prompt = f"""Ordne die folgenden Posts den verfügbaren Post-Typen zu:
|
||||
|
||||
=== VERFÜGBARE POST-TYPEN ===
|
||||
{type_descriptions}
|
||||
|
||||
=== POSTS ZUM KLASSIFIZIEREN ===
|
||||
{posts_text}
|
||||
|
||||
=== ANTWORT-FORMAT ===
|
||||
Gib ein JSON-Objekt zurück mit diesem Format:
|
||||
{{
|
||||
"classifications": [
|
||||
{{
|
||||
"post_id": "uuid-des-posts",
|
||||
"post_type_id": "uuid-des-typs oder null",
|
||||
"confidence": 0.8,
|
||||
"reasoning": "Kurze Begründung"
|
||||
}}
|
||||
]
|
||||
}}"""
|
||||
|
||||
try:
|
||||
response = await self.call_openai(
|
||||
system_prompt=system_prompt,
|
||||
user_prompt=user_prompt,
|
||||
model="gpt-4o-mini",
|
||||
temperature=0.2,
|
||||
response_format={"type": "json_object"}
|
||||
)
|
||||
|
||||
result = json.loads(response)
|
||||
classifications = result.get("classifications", [])
|
||||
|
||||
# Process and validate results
|
||||
valid_results = []
|
||||
for c in classifications:
|
||||
post_id = c.get("post_id")
|
||||
post_type_id = c.get("post_type_id")
|
||||
confidence = c.get("confidence", 0.5)
|
||||
|
||||
# Validate post_id exists
|
||||
matching_post = next((p for p in posts if str(p.id) == post_id), None)
|
||||
if not matching_post:
|
||||
logger.warning(f"Invalid post_id in classification: {post_id}")
|
||||
continue
|
||||
|
||||
# Validate post_type_id
|
||||
if post_type_id and post_type_id != "null" and post_type_id not in valid_type_ids:
|
||||
logger.warning(f"Invalid post_type_id in classification: {post_type_id}")
|
||||
continue
|
||||
|
||||
if post_type_id and post_type_id != "null":
|
||||
valid_results.append({
|
||||
"post_id": matching_post.id,
|
||||
"post_type_id": UUID(post_type_id),
|
||||
"classification_method": "semantic",
|
||||
"classification_confidence": min(1.0, max(0.3, confidence))
|
||||
})
|
||||
|
||||
return valid_results
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Semantic classification failed: {e}")
|
||||
return []
|
||||
|
||||
async def classify_single_post(
|
||||
self,
|
||||
post: LinkedInPost,
|
||||
post_types: List[PostType]
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Classify a single post.
|
||||
|
||||
Args:
|
||||
post: The post to classify
|
||||
post_types: Available post types
|
||||
|
||||
Returns:
|
||||
Classification result or None
|
||||
"""
|
||||
results = await self.process([post], post_types)
|
||||
return results[0] if results else None
|
||||
335
src/agents/post_type_analyzer.py
Normal file
335
src/agents/post_type_analyzer.py
Normal file
@@ -0,0 +1,335 @@
|
||||
"""Post type analyzer agent for creating intensive analysis per post type."""
|
||||
import json
|
||||
import re
|
||||
from typing import Dict, Any, List
|
||||
from loguru import logger
|
||||
|
||||
from src.agents.base import BaseAgent
|
||||
from src.database.models import LinkedInPost, PostType
|
||||
|
||||
|
||||
class PostTypeAnalyzerAgent(BaseAgent):
|
||||
"""Agent for analyzing post types based on their classified posts."""
|
||||
|
||||
MIN_POSTS_FOR_ANALYSIS = 3 # Minimum posts needed for meaningful analysis
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize post type analyzer agent."""
|
||||
super().__init__("PostTypeAnalyzer")
|
||||
|
||||
async def process(
|
||||
self,
|
||||
post_type: PostType,
|
||||
posts: List[LinkedInPost]
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Analyze a post type based on its posts.
|
||||
|
||||
Args:
|
||||
post_type: The post type to analyze
|
||||
posts: Posts belonging to this type
|
||||
|
||||
Returns:
|
||||
Analysis dictionary with patterns and insights
|
||||
"""
|
||||
if len(posts) < self.MIN_POSTS_FOR_ANALYSIS:
|
||||
logger.warning(f"Not enough posts for analysis: {len(posts)} < {self.MIN_POSTS_FOR_ANALYSIS}")
|
||||
return {
|
||||
"error": f"Mindestens {self.MIN_POSTS_FOR_ANALYSIS} Posts benötigt",
|
||||
"post_count": len(posts),
|
||||
"sufficient_data": False
|
||||
}
|
||||
|
||||
logger.info(f"Analyzing post type '{post_type.name}' with {len(posts)} posts")
|
||||
|
||||
# Prepare posts for analysis
|
||||
posts_text = self._prepare_posts_for_analysis(posts)
|
||||
|
||||
# Get comprehensive analysis from LLM
|
||||
analysis = await self._analyze_posts(post_type, posts_text, len(posts))
|
||||
|
||||
# Add metadata
|
||||
analysis["post_count"] = len(posts)
|
||||
analysis["sufficient_data"] = True
|
||||
analysis["post_type_name"] = post_type.name
|
||||
|
||||
logger.info(f"Analysis complete for '{post_type.name}'")
|
||||
return analysis
|
||||
|
||||
def _prepare_posts_for_analysis(self, posts: List[LinkedInPost]) -> str:
|
||||
"""Prepare posts text for analysis."""
|
||||
posts_sections = []
|
||||
for i, post in enumerate(posts, 1):
|
||||
# Include full post text
|
||||
posts_sections.append(f"=== POST {i} ===\n{post.post_text}\n=== ENDE POST {i} ===")
|
||||
return "\n\n".join(posts_sections)
|
||||
|
||||
async def _analyze_posts(
|
||||
self,
|
||||
post_type: PostType,
|
||||
posts_text: str,
|
||||
post_count: int
|
||||
) -> Dict[str, Any]:
|
||||
"""Run comprehensive analysis on posts."""
|
||||
|
||||
system_prompt = """Du bist ein erfahrener LinkedIn Content-Analyst und Ghostwriter-Coach.
|
||||
Deine Aufgabe ist es, Muster und Stilelemente aus einer Sammlung von Posts zu extrahieren,
|
||||
um einen "Styleguide" für diesen Post-Typ zu erstellen.
|
||||
|
||||
Sei SEHR SPEZIFISCH und nutze ECHTE BEISPIELE aus den Posts!
|
||||
Keine generischen Beschreibungen - immer konkrete Auszüge und Formulierungen.
|
||||
|
||||
Antworte im JSON-Format."""
|
||||
|
||||
user_prompt = f"""Analysiere die folgenden {post_count} Posts vom Typ "{post_type.name}".
|
||||
{f'Beschreibung: {post_type.description}' if post_type.description else ''}
|
||||
|
||||
=== DIE POSTS ===
|
||||
{posts_text}
|
||||
|
||||
=== DEINE ANALYSE ===
|
||||
|
||||
Erstelle eine detaillierte Analyse im folgenden JSON-Format:
|
||||
|
||||
{{
|
||||
"structure_patterns": {{
|
||||
"typical_structure": "Beschreibe die typische Struktur (z.B. Hook → Problem → Lösung → CTA)",
|
||||
"paragraph_count": "Typische Anzahl Absätze",
|
||||
"paragraph_length": "Typische Absatzlänge in Worten",
|
||||
"uses_lists": true/false,
|
||||
"list_style": "Wenn Listen: Wie werden sie formatiert? (Bullets, Nummern, Emojis)",
|
||||
"structure_template": "Eine Vorlage für die Struktur"
|
||||
}},
|
||||
|
||||
"language_style": {{
|
||||
"tone": "Haupttonalität (z.B. inspirierend, sachlich, provokativ)",
|
||||
"secondary_tones": ["Weitere Tonalitäten"],
|
||||
"perspective": "Ich-Perspektive, Du-Ansprache, Wir-Form?",
|
||||
"energy_level": 1-10,
|
||||
"formality": "formell/informell/mix",
|
||||
"sentence_types": "Kurz und knackig vs. ausführlich vs. mix",
|
||||
"typical_sentence_starters": ["Echte Beispiele wie Sätze beginnen"],
|
||||
"signature_phrases": ["Wiederkehrende Formulierungen"]
|
||||
}},
|
||||
|
||||
"hooks": {{
|
||||
"hook_types": ["Welche Hook-Arten werden verwendet (Frage, Statement, Statistik, Story...)"],
|
||||
"real_examples": [
|
||||
{{
|
||||
"hook": "Der genaue Hook-Text",
|
||||
"type": "Art des Hooks",
|
||||
"why_effective": "Warum funktioniert er?"
|
||||
}}
|
||||
],
|
||||
"hook_patterns": ["Muster die sich wiederholen"],
|
||||
"average_hook_length": "Wie lang sind Hooks typischerweise?"
|
||||
}},
|
||||
|
||||
"ctas": {{
|
||||
"cta_types": ["Welche CTA-Arten (Frage, Aufforderung, Teilen-Bitte...)"],
|
||||
"real_examples": [
|
||||
{{
|
||||
"cta": "Der genaue CTA-Text",
|
||||
"type": "Art des CTAs"
|
||||
}}
|
||||
],
|
||||
"cta_position": "Wo steht der CTA typischerweise?",
|
||||
"cta_intensity": "Wie direkt/stark ist der CTA?"
|
||||
}},
|
||||
|
||||
"visual_patterns": {{
|
||||
"emoji_usage": {{
|
||||
"frequency": "hoch/mittel/niedrig/keine",
|
||||
"typical_emojis": ["Die häufigsten Emojis"],
|
||||
"placement": "Wo werden Emojis platziert?",
|
||||
"purpose": "Wofür werden sie genutzt?"
|
||||
}},
|
||||
"line_breaks": "Wie werden Absätze/Zeilenumbrüche genutzt?",
|
||||
"formatting": "Unicode-Fett, Großbuchstaben, Sonderzeichen?",
|
||||
"whitespace": "Viel/wenig Whitespace?"
|
||||
}},
|
||||
|
||||
"length_patterns": {{
|
||||
"average_words": "Durchschnittliche Wortanzahl",
|
||||
"range": "Von-bis Wortanzahl",
|
||||
"ideal_length": "Empfohlene Länge für diesen Typ"
|
||||
}},
|
||||
|
||||
"recurring_elements": {{
|
||||
"phrases": ["Wiederkehrende Phrasen und Formulierungen"],
|
||||
"transitions": ["Typische Übergänge zwischen Absätzen"],
|
||||
"closings": ["Typische Schlussformulierungen vor dem CTA"]
|
||||
}},
|
||||
|
||||
"content_focus": {{
|
||||
"main_themes": ["Hauptthemen dieses Post-Typs"],
|
||||
"value_proposition": "Welchen Mehrwert bieten diese Posts?",
|
||||
"target_emotion": "Welche Emotion soll beim Leser ausgelöst werden?"
|
||||
}},
|
||||
|
||||
"writing_guidelines": {{
|
||||
"dos": ["5-7 konkrete Empfehlungen was man TUN sollte"],
|
||||
"donts": ["3-5 konkrete Dinge die man VERMEIDEN sollte"],
|
||||
"key_success_factors": ["Was macht Posts dieses Typs erfolgreich?"]
|
||||
}}
|
||||
}}
|
||||
|
||||
WICHTIG:
|
||||
- Nutze ECHTE Textauszüge aus den Posts als Beispiele!
|
||||
- Sei spezifisch, nicht generisch
|
||||
- Wenn ein Muster nur in 1-2 Posts vorkommt, erwähne es trotzdem aber markiere es als "vereinzelt"
|
||||
- Alle Beispiele müssen aus den gegebenen Posts stammen"""
|
||||
|
||||
try:
|
||||
response = await self.call_openai(
|
||||
system_prompt=system_prompt,
|
||||
user_prompt=user_prompt,
|
||||
model="gpt-4o",
|
||||
temperature=0.3,
|
||||
response_format={"type": "json_object"}
|
||||
)
|
||||
|
||||
analysis = json.loads(response)
|
||||
return analysis
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Analysis failed: {e}")
|
||||
return {
|
||||
"error": str(e),
|
||||
"sufficient_data": True,
|
||||
"post_count": post_count
|
||||
}
|
||||
|
||||
async def analyze_multiple_types(
|
||||
self,
|
||||
post_types_with_posts: List[Dict[str, Any]]
|
||||
) -> Dict[str, Dict[str, Any]]:
|
||||
"""
|
||||
Analyze multiple post types.
|
||||
|
||||
Args:
|
||||
post_types_with_posts: List of dicts with 'post_type' and 'posts' keys
|
||||
|
||||
Returns:
|
||||
Dictionary mapping post_type_id to analysis
|
||||
"""
|
||||
results = {}
|
||||
|
||||
for item in post_types_with_posts:
|
||||
post_type = item["post_type"]
|
||||
posts = item["posts"]
|
||||
|
||||
try:
|
||||
analysis = await self.process(post_type, posts)
|
||||
results[str(post_type.id)] = analysis
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to analyze post type {post_type.name}: {e}")
|
||||
results[str(post_type.id)] = {
|
||||
"error": str(e),
|
||||
"sufficient_data": False
|
||||
}
|
||||
|
||||
return results
|
||||
|
||||
def get_writing_prompt_section(self, analysis: Dict[str, Any]) -> str:
|
||||
"""
|
||||
Generate a prompt section for the writer based on the analysis.
|
||||
|
||||
Args:
|
||||
analysis: The post type analysis
|
||||
|
||||
Returns:
|
||||
Formatted string for inclusion in writer prompts
|
||||
"""
|
||||
if not analysis.get("sufficient_data"):
|
||||
return ""
|
||||
|
||||
sections = []
|
||||
|
||||
# Structure
|
||||
if structure := analysis.get("structure_patterns"):
|
||||
sections.append(f"""
|
||||
STRUKTUR FÜR DIESEN POST-TYP:
|
||||
- Typische Struktur: {structure.get('typical_structure', 'Standard')}
|
||||
- Absätze: {structure.get('paragraph_count', '3-5')} Absätze
|
||||
- Listen: {'Ja' if structure.get('uses_lists') else 'Nein'}
|
||||
{f"- Listen-Stil: {structure.get('list_style')}" if structure.get('uses_lists') else ''}
|
||||
""")
|
||||
|
||||
# Language style
|
||||
if style := analysis.get("language_style"):
|
||||
sections.append(f"""
|
||||
SPRACH-STIL:
|
||||
- Tonalität: {style.get('tone', 'Professionell')}
|
||||
- Perspektive: {style.get('perspective', 'Ich')}
|
||||
- Energie-Level: {style.get('energy_level', 7)}/10
|
||||
- Formalität: {style.get('formality', 'informell')}
|
||||
|
||||
Typische Satzanfänge:
|
||||
{chr(10).join([f' - "{s}"' for s in style.get('typical_sentence_starters', [])[:5]])}
|
||||
|
||||
Signature Phrases:
|
||||
{chr(10).join([f' - "{p}"' for p in style.get('signature_phrases', [])[:5]])}
|
||||
""")
|
||||
|
||||
# Hooks
|
||||
if hooks := analysis.get("hooks"):
|
||||
hook_examples = hooks.get("real_examples", [])[:3]
|
||||
hook_text = "\n".join([f' - "{h.get("hook", "")}" ({h.get("type", "")})' for h in hook_examples])
|
||||
sections.append(f"""
|
||||
HOOK-MUSTER:
|
||||
Hook-Typen: {', '.join(hooks.get('hook_types', []))}
|
||||
|
||||
Echte Beispiele:
|
||||
{hook_text}
|
||||
|
||||
Muster: {', '.join(hooks.get('hook_patterns', [])[:3])}
|
||||
""")
|
||||
|
||||
# CTAs
|
||||
if ctas := analysis.get("ctas"):
|
||||
cta_examples = ctas.get("real_examples", [])[:3]
|
||||
cta_text = "\n".join([f' - "{c.get("cta", "")}"' for c in cta_examples])
|
||||
sections.append(f"""
|
||||
CTA-MUSTER:
|
||||
CTA-Typen: {', '.join(ctas.get('cta_types', []))}
|
||||
|
||||
Echte Beispiele:
|
||||
{cta_text}
|
||||
|
||||
Position: {ctas.get('cta_position', 'Am Ende')}
|
||||
""")
|
||||
|
||||
# Visual patterns
|
||||
if visual := analysis.get("visual_patterns"):
|
||||
emoji = visual.get("emoji_usage", {})
|
||||
sections.append(f"""
|
||||
VISUELLE ELEMENTE:
|
||||
- Emoji-Nutzung: {emoji.get('frequency', 'mittel')}
|
||||
- Typische Emojis: {' '.join(emoji.get('typical_emojis', [])[:8])}
|
||||
- Platzierung: {emoji.get('placement', 'Variabel')}
|
||||
- Formatierung: {visual.get('formatting', 'Standard')}
|
||||
""")
|
||||
|
||||
# Length
|
||||
if length := analysis.get("length_patterns"):
|
||||
sections.append(f"""
|
||||
LÄNGE:
|
||||
- Ideal: ca. {length.get('ideal_length', '200-300')} Wörter
|
||||
- Range: {length.get('range', '150-400')} Wörter
|
||||
""")
|
||||
|
||||
# Guidelines
|
||||
if guidelines := analysis.get("writing_guidelines"):
|
||||
dos = guidelines.get("dos", [])[:5]
|
||||
donts = guidelines.get("donts", [])[:3]
|
||||
sections.append(f"""
|
||||
WICHTIGE REGELN:
|
||||
DO:
|
||||
{chr(10).join([f' ✓ {d}' for d in dos])}
|
||||
|
||||
DON'T:
|
||||
{chr(10).join([f' ✗ {d}' for d in donts])}
|
||||
""")
|
||||
|
||||
return "\n".join(sections)
|
||||
300
src/agents/profile_analyzer.py
Normal file
300
src/agents/profile_analyzer.py
Normal file
@@ -0,0 +1,300 @@
|
||||
"""Profile analyzer agent."""
|
||||
import json
|
||||
from typing import Dict, Any, List
|
||||
from loguru import logger
|
||||
|
||||
from src.agents.base import BaseAgent
|
||||
from src.database.models import LinkedInProfile, LinkedInPost
|
||||
|
||||
|
||||
class ProfileAnalyzerAgent(BaseAgent):
|
||||
"""Agent for analyzing LinkedIn profiles and extracting writing patterns."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize profile analyzer agent."""
|
||||
super().__init__("ProfileAnalyzer")
|
||||
|
||||
async def process(
|
||||
self,
|
||||
profile: LinkedInProfile,
|
||||
posts: List[LinkedInPost],
|
||||
customer_data: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Analyze LinkedIn profile and extract writing patterns.
|
||||
|
||||
Args:
|
||||
profile: LinkedIn profile data
|
||||
posts: List of LinkedIn posts
|
||||
customer_data: Additional customer data from input file
|
||||
|
||||
Returns:
|
||||
Comprehensive profile analysis
|
||||
"""
|
||||
logger.info(f"Analyzing profile for: {profile.name}")
|
||||
|
||||
# Prepare analysis data
|
||||
profile_summary = {
|
||||
"name": profile.name,
|
||||
"headline": profile.headline,
|
||||
"summary": profile.summary,
|
||||
"industry": profile.industry,
|
||||
"location": profile.location
|
||||
}
|
||||
|
||||
# Prepare posts with engagement data - use up to 30 posts
|
||||
posts_with_engagement = self._prepare_posts_for_analysis(posts[:15])
|
||||
|
||||
# Also identify top performing posts by engagement
|
||||
top_posts = self._get_top_performing_posts(posts, limit=5)
|
||||
|
||||
system_prompt = self._get_system_prompt()
|
||||
user_prompt = self._get_user_prompt(profile_summary, posts_with_engagement, top_posts, customer_data)
|
||||
|
||||
response = await self.call_openai(
|
||||
system_prompt=system_prompt,
|
||||
user_prompt=user_prompt,
|
||||
model="gpt-4o",
|
||||
temperature=0.3,
|
||||
response_format={"type": "json_object"}
|
||||
)
|
||||
|
||||
# Parse JSON response
|
||||
analysis = json.loads(response)
|
||||
logger.info("Profile analysis completed successfully")
|
||||
|
||||
return analysis
|
||||
|
||||
def _prepare_posts_for_analysis(self, posts: List[LinkedInPost]) -> List[Dict[str, Any]]:
|
||||
"""Prepare posts with engagement data for analysis."""
|
||||
prepared = []
|
||||
for i, post in enumerate(posts):
|
||||
if not post.post_text:
|
||||
continue
|
||||
prepared.append({
|
||||
"index": i + 1,
|
||||
"text": post.post_text,
|
||||
"likes": post.likes or 0,
|
||||
"comments": post.comments or 0,
|
||||
"shares": post.shares or 0,
|
||||
"engagement_total": (post.likes or 0) + (post.comments or 0) * 2 + (post.shares or 0) * 3
|
||||
})
|
||||
return prepared
|
||||
|
||||
def _get_top_performing_posts(self, posts: List[LinkedInPost], limit: int = 5) -> List[Dict[str, Any]]:
|
||||
"""Get top performing posts by engagement."""
|
||||
posts_with_engagement = []
|
||||
for post in posts:
|
||||
if not post.post_text or len(post.post_text) < 50:
|
||||
continue
|
||||
engagement = (post.likes or 0) + (post.comments or 0) * 2 + (post.shares or 0) * 3
|
||||
posts_with_engagement.append({
|
||||
"text": post.post_text,
|
||||
"likes": post.likes or 0,
|
||||
"comments": post.comments or 0,
|
||||
"shares": post.shares or 0,
|
||||
"engagement_score": engagement
|
||||
})
|
||||
|
||||
# Sort by engagement and return top posts
|
||||
sorted_posts = sorted(posts_with_engagement, key=lambda x: x["engagement_score"], reverse=True)
|
||||
return sorted_posts[:limit]
|
||||
|
||||
def _get_system_prompt(self) -> str:
|
||||
"""Get system prompt for profile analysis."""
|
||||
return """Du bist ein hochspezialisierter AI-Analyst für LinkedIn-Profile und Content-Strategie.
|
||||
|
||||
Deine Aufgabe ist es, aus LinkedIn-Profildaten und Posts ein umfassendes Content-Analyse-Profil zu erstellen, das als BLAUPAUSE für das Schreiben neuer Posts dient.
|
||||
|
||||
WICHTIG: Extrahiere ECHTE BEISPIELE aus den Posts! Keine generischen Beschreibungen.
|
||||
|
||||
Das Profil soll folgende Dimensionen analysieren:
|
||||
|
||||
1. **Schreibstil & Tonalität**
|
||||
- Wie schreibt die Person? (formal, locker, inspirierend, provokativ, etc.)
|
||||
- Welche Perspektive wird genutzt? (Ich, Wir, Man)
|
||||
- Wie ist die Ansprache? (Du, Sie, neutral)
|
||||
- Satzdynamik und Rhythmus
|
||||
|
||||
2. **Phrasen-Bibliothek (KRITISCH!)**
|
||||
- Hook-Phrasen: Wie beginnen Posts? Extrahiere 5-10 ECHTE Beispiele!
|
||||
- Übergangs-Phrasen: Wie werden Absätze verbunden?
|
||||
- Emotionale Ausdrücke: Ausrufe, Begeisterung, etc.
|
||||
- CTA-Phrasen: Wie werden Leser aktiviert?
|
||||
- Signature Phrases: Wiederkehrende Markenzeichen
|
||||
|
||||
3. **Struktur-Templates**
|
||||
- Analysiere die STRUKTUR der Top-Posts
|
||||
- Erstelle 2-3 konkrete Templates (z.B. "Hook → Flashback → Erkenntnis → CTA")
|
||||
- Typische Satzanfänge für jeden Abschnitt
|
||||
|
||||
4. **Visuelle Muster**
|
||||
- Emoji-Nutzung (welche, wo, wie oft)
|
||||
- Unicode-Formatierung (fett, kursiv)
|
||||
- Strukturierung (Absätze, Listen, etc.)
|
||||
|
||||
5. **Audience Insights**
|
||||
- Wer ist die Zielgruppe?
|
||||
- Welche Probleme werden adressiert?
|
||||
- Welcher Mehrwert wird geboten?
|
||||
|
||||
Gib deine Analyse als strukturiertes JSON zurück."""
|
||||
|
||||
def _get_user_prompt(
|
||||
self,
|
||||
profile_summary: Dict[str, Any],
|
||||
posts_with_engagement: List[Dict[str, Any]],
|
||||
top_posts: List[Dict[str, Any]],
|
||||
customer_data: Dict[str, Any]
|
||||
) -> str:
|
||||
"""Get user prompt with data for analysis."""
|
||||
# Format all posts with engagement data
|
||||
all_posts_text = ""
|
||||
for post in posts_with_engagement:
|
||||
all_posts_text += f"\n--- Post {post['index']} (Likes: {post['likes']}, Comments: {post['comments']}, Shares: {post['shares']}) ---\n"
|
||||
all_posts_text += post['text'][:2000] # Limit each post to 2000 chars
|
||||
all_posts_text += "\n"
|
||||
|
||||
# Format top performing posts
|
||||
top_posts_text = ""
|
||||
if top_posts:
|
||||
for i, post in enumerate(top_posts, 1):
|
||||
top_posts_text += f"\n--- TOP POST {i} (Engagement Score: {post['engagement_score']}, Likes: {post['likes']}, Comments: {post['comments']}) ---\n"
|
||||
top_posts_text += post['text'][:2000]
|
||||
top_posts_text += "\n"
|
||||
|
||||
return f"""Bitte analysiere folgendes LinkedIn-Profil BASIEREND AUF DEN ECHTEN POSTS:
|
||||
|
||||
**PROFIL-INFORMATIONEN:**
|
||||
- Name: {profile_summary.get('name', 'N/A')}
|
||||
- Headline: {profile_summary.get('headline', 'N/A')}
|
||||
- Branche: {profile_summary.get('industry', 'N/A')}
|
||||
- Location: {profile_summary.get('location', 'N/A')}
|
||||
- Summary: {profile_summary.get('summary', 'N/A')}
|
||||
|
||||
**ZUSÄTZLICHE KUNDENDATEN (Persona, Style Guide, etc.):**
|
||||
{json.dumps(customer_data, indent=2, ensure_ascii=False)}
|
||||
|
||||
**TOP-PERFORMING POSTS (die erfolgreichsten Posts - ANALYSIERE DIESE BESONDERS GENAU!):**
|
||||
{top_posts_text if top_posts_text else "Keine Engagement-Daten verfügbar"}
|
||||
|
||||
**ALLE POSTS ({len(posts_with_engagement)} Posts mit Engagement-Daten):**
|
||||
{all_posts_text}
|
||||
|
||||
---
|
||||
|
||||
WICHTIG: Analysiere die ECHTEN POSTS sehr genau! Deine Analyse muss auf den tatsächlichen Mustern basieren, nicht auf Annahmen. Extrahiere WÖRTLICHE ZITATE wo möglich!
|
||||
|
||||
Achte besonders auf:
|
||||
1. Die TOP-PERFORMING Posts - was macht sie erfolgreich?
|
||||
2. Wiederkehrende Phrasen und Formulierungen - WÖRTLICH extrahieren!
|
||||
3. Wie beginnen die Posts (Hooks)? - ECHTE BEISPIELE sammeln!
|
||||
4. Wie enden die Posts (CTAs)?
|
||||
5. Emoji-Verwendung (welche, wo, wie oft)
|
||||
6. Länge und Struktur der Absätze
|
||||
7. Typische Satzanfänge und Übergänge
|
||||
|
||||
Erstelle eine umfassende Analyse im folgenden JSON-Format:
|
||||
|
||||
{{
|
||||
"writing_style": {{
|
||||
"tone": "Beschreibung der Tonalität basierend auf den echten Posts",
|
||||
"perspective": "Ich/Wir/Man/Gemischt - mit Beispielen aus den Posts",
|
||||
"form_of_address": "Du/Sie/Neutral - wie spricht die Person die Leser an?",
|
||||
"sentence_dynamics": "Kurze Sätze? Lange Sätze? Mischung? Fragen?",
|
||||
"average_post_length": "Kurz/Mittel/Lang",
|
||||
"average_word_count": 0
|
||||
}},
|
||||
"linguistic_fingerprint": {{
|
||||
"energy_level": 0,
|
||||
"shouting_usage": "Beschreibung mit konkreten Beispielen aus den Posts",
|
||||
"punctuation_patterns": "Beschreibung (!!!, ..., ?, etc.)",
|
||||
"signature_phrases": ["ECHTE Phrasen aus den Posts", "die wiederholt vorkommen"],
|
||||
"narrative_anchors": ["Storytelling-Elemente", "die die Person nutzt"]
|
||||
}},
|
||||
"phrase_library": {{
|
||||
"hook_phrases": [
|
||||
"ECHTE Hook-Sätze aus den Posts wörtlich kopiert",
|
||||
"Mindestens 5-8 verschiedene Beispiele",
|
||||
"z.B. '𝗞𝗜-𝗦𝘂𝗰𝗵𝗲 𝗶𝘀𝘁 𝗱𝗲𝗿 𝗲𝗿𝘀𝘁𝗲 𝗦𝗰𝗵𝗿𝗶𝘁𝘁 𝗶𝗺 𝗦𝗮𝗹𝗲𝘀 𝗙𝘂𝗻𝗻𝗲𝗹.'"
|
||||
],
|
||||
"transition_phrases": [
|
||||
"ECHTE Übergangssätze zwischen Absätzen",
|
||||
"z.B. 'Und wisst ihr was?', 'Aber Moment...', 'Was das mit X zu tun hat?'"
|
||||
],
|
||||
"emotional_expressions": [
|
||||
"Ausrufe und emotionale Marker",
|
||||
"z.B. 'Halleluja!', 'Sorry to say!!', 'Galopp!!!!'"
|
||||
],
|
||||
"cta_phrases": [
|
||||
"ECHTE Call-to-Action Formulierungen",
|
||||
"z.B. 'Was denkt ihr?', 'Seid ihr dabei?', 'Lasst uns darüber sprechen.'"
|
||||
],
|
||||
"filler_expressions": [
|
||||
"Typische Füllwörter und Ausdrücke",
|
||||
"z.B. 'Ich meine...', 'Wisst ihr...', 'Ok, ok...'"
|
||||
]
|
||||
}},
|
||||
"structure_templates": {{
|
||||
"primary_structure": "Die häufigste Struktur beschreiben, z.B. 'Unicode-Hook → Persönliche Anekdote → Erkenntnis → Bullet Points → CTA'",
|
||||
"template_examples": [
|
||||
{{
|
||||
"name": "Storytelling-Post",
|
||||
"structure": ["Fetter Hook mit Zitat", "Flashback/Anekdote", "Erkenntnis/Lesson", "Praktische Tipps", "CTA-Frage"],
|
||||
"example_post_index": 1
|
||||
}},
|
||||
{{
|
||||
"name": "Insight-Post",
|
||||
"structure": ["Provokante These", "Begründung", "Beispiel", "Handlungsaufforderung"],
|
||||
"example_post_index": 2
|
||||
}}
|
||||
],
|
||||
"typical_sentence_starters": [
|
||||
"ECHTE Satzanfänge aus den Posts",
|
||||
"z.B. 'Ich glaube, dass...', 'Was mir aufgefallen ist...', 'Das Verrückte ist...'"
|
||||
],
|
||||
"paragraph_transitions": [
|
||||
"Wie werden Absätze eingeleitet?",
|
||||
"z.B. 'Und...', 'Aber:', 'Das bedeutet:'"
|
||||
]
|
||||
}},
|
||||
"tone_analysis": {{
|
||||
"primary_tone": "Haupttonalität basierend auf den Posts",
|
||||
"emotional_range": "Welche Emotionen werden angesprochen?",
|
||||
"authenticity_markers": ["Was macht den Stil einzigartig?", "Erkennbare Merkmale"]
|
||||
}},
|
||||
"topic_patterns": {{
|
||||
"main_topics": ["Hauptthemen aus den Posts"],
|
||||
"content_pillars": ["Content-Säulen"],
|
||||
"expertise_areas": ["Expertise-Bereiche"],
|
||||
"expertise_level": "Anfänger/Fortgeschritten/Experte"
|
||||
}},
|
||||
"audience_insights": {{
|
||||
"target_audience": "Wer wird angesprochen?",
|
||||
"pain_points_addressed": ["Probleme die adressiert werden"],
|
||||
"value_proposition": "Welchen Mehrwert bietet die Person?",
|
||||
"industry_context": "Branchenkontext"
|
||||
}},
|
||||
"visual_patterns": {{
|
||||
"emoji_usage": {{
|
||||
"emojis": ["Liste der tatsächlich verwendeten Emojis"],
|
||||
"placement": "Anfang/Ende/Inline/Zwischen Absätzen",
|
||||
"frequency": "Selten/Mittel/Häufig - pro Post durchschnittlich X"
|
||||
}},
|
||||
"unicode_formatting": "Wird ✓, →, •, 𝗙𝗲𝘁𝘁 etc. verwendet? Wo?",
|
||||
"structure_preferences": "Absätze/Listen/Einzeiler/Nummeriert"
|
||||
}},
|
||||
"content_strategy": {{
|
||||
"hook_patterns": "Wie werden Posts KONKRET eröffnet? Beschreibung des Musters",
|
||||
"cta_style": "Wie sehen die CTAs aus? Frage? Aufforderung? Keine?",
|
||||
"storytelling_approach": "Persönliche Geschichten? Metaphern? Case Studies?",
|
||||
"post_structure": "Hook → Body → CTA? Oder anders?"
|
||||
}},
|
||||
"best_performing_patterns": {{
|
||||
"what_works": "Was machen die Top-Posts anders/besser?",
|
||||
"successful_hooks": ["WÖRTLICHE Beispiel-Hooks aus Top-Posts"],
|
||||
"engagement_drivers": ["Was treibt Engagement?"]
|
||||
}}
|
||||
}}
|
||||
|
||||
KRITISCH: Bei phrase_library und structure_templates müssen ECHTE, WÖRTLICHE Beispiele aus den Posts stehen! Keine generischen Beschreibungen!"""
|
||||
630
src/agents/researcher.py
Normal file
630
src/agents/researcher.py
Normal file
@@ -0,0 +1,630 @@
|
||||
"""Research agent using Perplexity."""
|
||||
import json
|
||||
import random
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, Any, List
|
||||
from loguru import logger
|
||||
|
||||
from src.agents.base import BaseAgent
|
||||
|
||||
|
||||
class ResearchAgent(BaseAgent):
|
||||
"""Agent for researching new content topics using Perplexity."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize research agent."""
|
||||
super().__init__("Researcher")
|
||||
|
||||
async def process(
|
||||
self,
|
||||
profile_analysis: Dict[str, Any],
|
||||
existing_topics: List[str],
|
||||
customer_data: Dict[str, Any],
|
||||
example_posts: List[str] = None,
|
||||
post_type: Any = None,
|
||||
post_type_analysis: Dict[str, Any] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Research new content topics.
|
||||
|
||||
Args:
|
||||
profile_analysis: Profile analysis results
|
||||
existing_topics: List of already covered topics
|
||||
customer_data: Customer data (contains persona, style_guide, etc.)
|
||||
example_posts: List of the person's actual posts for style reference
|
||||
post_type: Optional PostType object for targeted research
|
||||
post_type_analysis: Optional post type analysis for context
|
||||
|
||||
Returns:
|
||||
Research results with suggested topics
|
||||
"""
|
||||
logger.info("Starting research for new content topics")
|
||||
if post_type:
|
||||
logger.info(f"Targeting research for post type: {post_type.name}")
|
||||
|
||||
# Extract key information from profile analysis
|
||||
audience_insights = profile_analysis.get("audience_insights", {})
|
||||
topic_patterns = profile_analysis.get("topic_patterns", {})
|
||||
|
||||
industry = audience_insights.get("industry_context", "Business")
|
||||
target_audience = audience_insights.get("target_audience", "Professionals")
|
||||
content_pillars = topic_patterns.get("content_pillars", [])
|
||||
pain_points = audience_insights.get("pain_points_addressed", [])
|
||||
value_proposition = audience_insights.get("value_proposition", "")
|
||||
|
||||
# Extract customer-specific data
|
||||
persona = customer_data.get("persona", "") if customer_data else ""
|
||||
|
||||
# STEP 1: Use Perplexity for REAL internet research (has live data!)
|
||||
logger.info("Step 1: Researching with Perplexity (live internet data)")
|
||||
perplexity_prompt = self._get_perplexity_prompt(
|
||||
industry=industry,
|
||||
target_audience=target_audience,
|
||||
content_pillars=content_pillars,
|
||||
existing_topics=existing_topics,
|
||||
pain_points=pain_points,
|
||||
persona=persona
|
||||
)
|
||||
|
||||
# Dynamic system prompt for variety
|
||||
system_prompts = [
|
||||
"Du bist ein investigativer Journalist. Finde die neuesten, spannendsten Entwicklungen mit harten Fakten.",
|
||||
"Du bist ein Branchen-Analyst. Identifiziere aktuelle Trends und Marktbewegungen mit konkreten Daten.",
|
||||
"Du bist ein Trend-Scout. Spüre auf, was diese Woche wirklich neu und relevant ist.",
|
||||
"Du bist ein Research-Spezialist. Finde aktuelle Studien, Statistiken und News mit Quellenangaben."
|
||||
]
|
||||
|
||||
raw_research = await self.call_perplexity(
|
||||
system_prompt=random.choice(system_prompts),
|
||||
user_prompt=perplexity_prompt,
|
||||
model="sonar-pro"
|
||||
)
|
||||
|
||||
logger.info("Step 2: Transforming research into personalized topic ideas")
|
||||
# STEP 2: Transform raw research into PERSONALIZED topic suggestions
|
||||
transform_prompt = self._get_transform_prompt(
|
||||
raw_research=raw_research,
|
||||
target_audience=target_audience,
|
||||
persona=persona,
|
||||
content_pillars=content_pillars,
|
||||
example_posts=example_posts or [],
|
||||
existing_topics=existing_topics,
|
||||
post_type=post_type,
|
||||
post_type_analysis=post_type_analysis
|
||||
)
|
||||
|
||||
response = await self.call_openai(
|
||||
system_prompt=self._get_topic_creator_system_prompt(),
|
||||
user_prompt=transform_prompt,
|
||||
model="gpt-4o",
|
||||
temperature=0.7, # Higher for creative topic angles
|
||||
response_format={"type": "json_object"}
|
||||
)
|
||||
|
||||
# Parse JSON response
|
||||
result = json.loads(response)
|
||||
suggested_topics = result.get("topics", [])
|
||||
|
||||
# STEP 3: Ensure diversity - filter out similar topics
|
||||
suggested_topics = self._ensure_diversity(suggested_topics)
|
||||
|
||||
# Parse research results
|
||||
research_results = {
|
||||
"raw_response": response,
|
||||
"suggested_topics": suggested_topics,
|
||||
"industry": industry,
|
||||
"target_audience": target_audience
|
||||
}
|
||||
|
||||
logger.info(f"Research completed with {len(research_results['suggested_topics'])} topic suggestions")
|
||||
return research_results
|
||||
|
||||
def _get_topic_creator_system_prompt(self) -> str:
|
||||
"""Get system prompt for transforming research into personalized topics."""
|
||||
return """Du bist ein LinkedIn Content-Stratege, der aus Recherche-Ergebnissen KONKRETE, PERSONALISIERTE Themenvorschläge erstellt.
|
||||
|
||||
WICHTIG: Du erstellst KEINE Schlagzeilen oder News-Titel!
|
||||
Du erstellst KONKRETE CONTENT-IDEEN mit:
|
||||
- Einem klaren ANGLE (Perspektive/Blickwinkel)
|
||||
- Einer konkreten HOOK-IDEE
|
||||
- Einem NARRATIV das die Person erzählen könnte
|
||||
|
||||
Der Unterschied:
|
||||
❌ SCHLECHT (Schlagzeile): "KI verändert den Arbeitsmarkt"
|
||||
✅ GUT (Themenvorschlag): "Warum ich als [Rolle] plötzlich 50% meiner Zeit mit KI-Prompts verbringe - und was das für mein Team bedeutet"
|
||||
|
||||
❌ SCHLECHT: "Neue Studie zu Remote Work"
|
||||
✅ GUT: "3 Erkenntnisse aus der Stanford Remote-Studie, die mich als Führungskraft überrascht haben"
|
||||
|
||||
❌ SCHLECHT: "Fachkräftemangel in der IT"
|
||||
✅ GUT: "Unpopuläre Meinung: Wir haben keinen Fachkräftemangel - wir haben ein Ausbildungsproblem. Hier ist was ich damit meine..."
|
||||
|
||||
Deine Themenvorschläge müssen:
|
||||
1. ZUR PERSON PASSEN - Klingt wie etwas das diese spezifische Person posten würde
|
||||
2. EINEN KONKRETEN ANGLE HABEN - Nicht "über X schreiben" sondern "diesen spezifischen Aspekt von X aus dieser Perspektive beleuchten"
|
||||
3. EINEN HOOK VORSCHLAGEN - Eine konkrete Idee wie der Post starten könnte
|
||||
4. HINTERGRUND-INFOS LIEFERN - Fakten/Daten aus der Recherche die die Person nutzen kann
|
||||
5. ABWECHSLUNGSREICH SEIN - Verschiedene Formate und Kategorien
|
||||
|
||||
Antworte als JSON."""
|
||||
|
||||
def _get_system_prompt(self) -> str:
|
||||
"""Get system prompt for research (legacy, kept for compatibility)."""
|
||||
return """Du bist ein hochspezialisierter Trend-Analyst und Content-Researcher.
|
||||
|
||||
Deine Mission ist es, aktuelle, hochrelevante Content-Themen für LinkedIn zu identifizieren.
|
||||
|
||||
Du sollst:
|
||||
1. Aktuelle Trends, News und Diskussionen der letzten 7-14 Tage recherchieren
|
||||
2. Themen finden, die für die spezifische Zielgruppe relevant sind
|
||||
3. Verschiedene Kategorien abdecken:
|
||||
- Aktuelle News & Studien
|
||||
- Schmerzpunkt-Lösungen
|
||||
- Konträre Trends (gegen Mainstream-Meinung)
|
||||
- Emerging Topics
|
||||
|
||||
Für jedes Thema sollst du bereitstellen:
|
||||
- Einen prägnanten Titel
|
||||
- Den Kern-Fakt (mit Daten, Quellen, Beispielen)
|
||||
- Warum es relevant ist für die Zielgruppe
|
||||
- Die Kategorie
|
||||
|
||||
Fokussiere dich auf Themen, die:
|
||||
- AKTUELL sind (letzte 1-2 Wochen)
|
||||
- KONKRET sind (mit Daten/Fakten belegt)
|
||||
- RELEVANT sind für die Zielgruppe
|
||||
- UNIQUE sind (nicht bereits behandelt)
|
||||
|
||||
Gib deine Antwort als JSON zurück."""
|
||||
|
||||
def _get_user_prompt(
|
||||
self,
|
||||
industry: str,
|
||||
target_audience: str,
|
||||
content_pillars: List[str],
|
||||
existing_topics: List[str],
|
||||
pain_points: List[str] = None,
|
||||
value_proposition: str = "",
|
||||
persona: str = ""
|
||||
) -> str:
|
||||
"""Get user prompt for research."""
|
||||
pillars_text = ", ".join(content_pillars) if content_pillars else "Verschiedene Business-Themen"
|
||||
existing_text = ", ".join(existing_topics[:20]) if existing_topics else "Keine"
|
||||
pain_points_text = ", ".join(pain_points) if pain_points else "Nicht spezifiziert"
|
||||
|
||||
# Build persona section if available
|
||||
persona_section = ""
|
||||
if persona:
|
||||
persona_section = f"""
|
||||
**PERSONA DER PERSON (WICHTIG - Themen müssen zu dieser Expertise passen!):**
|
||||
{persona[:800]}
|
||||
"""
|
||||
|
||||
return f"""Recherchiere aktuelle LinkedIn-Content-Themen für folgendes Profil:
|
||||
|
||||
**KONTEXT:**
|
||||
- Branche: {industry}
|
||||
- Zielgruppe: {target_audience}
|
||||
- Content-Säulen: {pillars_text}
|
||||
- Pain Points der Zielgruppe: {pain_points_text}
|
||||
- Value Proposition: {value_proposition or 'Mehrwert für die Zielgruppe bieten'}
|
||||
{persona_section}
|
||||
**BEREITS BEHANDELTE THEMEN (diese NICHT vorschlagen):**
|
||||
{existing_text}
|
||||
|
||||
**AUFGABE:**
|
||||
Finde 5-7 verschiedene aktuelle Themen, die:
|
||||
1. ZUR EXPERTISE/PERSONA der Person passen
|
||||
2. Die PAIN POINTS der Zielgruppe addressieren
|
||||
3. AUTHENTISCH von dieser Person kommen könnten
|
||||
4. NICHT generisch oder beliebig sind
|
||||
|
||||
Kategorien:
|
||||
1. **News-Flash**: Aktuelle Nachrichten, Studien oder Entwicklungen
|
||||
2. **Schmerzpunkt-Löser**: Probleme/Diskussionen, die die Zielgruppe aktuell beschäftigen
|
||||
3. **Konträrer Trend**: Entwicklungen, die gegen die herkömmliche Meinung verstoßen
|
||||
4. **Emerging Topic**: Neue Trends, die gerade an Fahrt gewinnen
|
||||
|
||||
WICHTIG: Themen müssen zur Person passen! Ein Experte für {industry} würde keine generischen "Productivity-Tips" posten, sondern spezifische Insights aus seinem Fachgebiet.
|
||||
|
||||
Fokus auf deutsche/DACH-Region relevante Themen.
|
||||
|
||||
Gib deine Antwort im folgenden JSON-Format zurück:
|
||||
|
||||
{{
|
||||
"topics": [
|
||||
{{
|
||||
"title": "Prägnanter Arbeitstitel (spezifisch, nicht generisch!)",
|
||||
"category": "News-Flash / Schmerzpunkt-Löser / Konträrer Trend / Emerging Topic",
|
||||
"fact": "Detaillierte Zusammenfassung mit Daten, Fakten, Beispielen - SPEZIFISCH für diese Branche",
|
||||
"relevance": "Warum ist das für {target_audience} wichtig und warum sollte DIESE Person darüber schreiben?",
|
||||
"source": "Quellenangaben (Studien, Artikel, Statistiken)"
|
||||
}}
|
||||
]
|
||||
}}"""
|
||||
|
||||
def _get_perplexity_prompt(
|
||||
self,
|
||||
industry: str,
|
||||
target_audience: str,
|
||||
content_pillars: List[str],
|
||||
existing_topics: List[str],
|
||||
pain_points: List[str] = None,
|
||||
persona: str = ""
|
||||
) -> str:
|
||||
"""Get prompt for Perplexity research (optimized for live internet search)."""
|
||||
pillars_text = ", ".join(content_pillars) if content_pillars else "Business-Themen"
|
||||
existing_text = ", ".join(existing_topics[:20]) if existing_topics else "Keine bisherigen Themen"
|
||||
pain_points_text = ", ".join(pain_points) if pain_points else "Allgemeine Business-Probleme"
|
||||
|
||||
# Current date for time-specific searches
|
||||
today = datetime.now()
|
||||
date_str = today.strftime("%d. %B %Y")
|
||||
week_ago = (today - timedelta(days=7)).strftime("%d. %B %Y")
|
||||
|
||||
persona_hint = ""
|
||||
if persona:
|
||||
persona_hint = f"\nEXPERTISE DER PERSON: {persona[:600]}\n"
|
||||
|
||||
# Randomize the research focus for variety
|
||||
research_angles = [
|
||||
{
|
||||
"name": "Breaking News & Studien",
|
||||
"focus": "Suche nach brandneuen Studien, Reports, Umfragen oder Nachrichten",
|
||||
"examples": "Neue Statistiken, Forschungsergebnisse, Unternehmens-Announcements"
|
||||
},
|
||||
{
|
||||
"name": "Kontroverse & Debatten",
|
||||
"focus": "Suche nach aktuellen Kontroversen, Meinungsverschiedenheiten, heißen Diskussionen",
|
||||
"examples": "Polarisierende Meinungen, Kritik an Trends, unerwartete Entwicklungen"
|
||||
},
|
||||
{
|
||||
"name": "Technologie & Innovation",
|
||||
"focus": "Suche nach neuen Tools, Technologien, Methoden die gerade aufkommen",
|
||||
"examples": "Neue Software, AI-Entwicklungen, Prozess-Innovationen"
|
||||
},
|
||||
{
|
||||
"name": "Markt & Wirtschaft",
|
||||
"focus": "Suche nach wirtschaftlichen Entwicklungen, Marktveränderungen, Branchen-Shifts",
|
||||
"examples": "Fusionen, Insolvenzen, Markteintritt, Regulierungen"
|
||||
},
|
||||
{
|
||||
"name": "Menschen & Karriere",
|
||||
"focus": "Suche nach Personalien, Karriere-Trends, Arbeitsmarkt-Entwicklungen",
|
||||
"examples": "Führungswechsel, Hiring-Trends, Remote Work Updates, Skill-Demands"
|
||||
},
|
||||
{
|
||||
"name": "Fails & Learnings",
|
||||
"focus": "Suche nach öffentlichen Fehlern, Shitstorms, Lessons Learned",
|
||||
"examples": "PR-Desaster, gescheiterte Launches, öffentliche Kritik"
|
||||
}
|
||||
]
|
||||
|
||||
# Pick 3-4 random angles for this research session
|
||||
selected_angles = random.sample(research_angles, min(4, len(research_angles)))
|
||||
angles_text = "\n".join([
|
||||
f"- **{angle['name']}**: {angle['focus']} (z.B. {angle['examples']})"
|
||||
for angle in selected_angles
|
||||
])
|
||||
|
||||
# Random seed words for more variety
|
||||
seed_variations = [
|
||||
f"Was ist DIESE WOCHE ({week_ago} bis {date_str}) passiert in {industry}?",
|
||||
f"Welche BREAKING NEWS gibt es HEUTE ({date_str}) oder diese Woche in {industry}?",
|
||||
f"Was diskutiert die {industry}-Branche AKTUELL ({date_str})?",
|
||||
f"Welche NEUEN Entwicklungen gibt es seit {week_ago} in {industry}?"
|
||||
]
|
||||
seed_question = random.choice(seed_variations)
|
||||
|
||||
return f"""AKTUELLES DATUM: {date_str}
|
||||
|
||||
{seed_question}
|
||||
{persona_hint}
|
||||
KONTEXT:
|
||||
- Branche: {industry}
|
||||
- Zielgruppe: {target_audience}
|
||||
- Themen-Fokus: {pillars_text}
|
||||
- Pain Points: {pain_points_text}
|
||||
|
||||
RECHERCHE-SCHWERPUNKTE FÜR DIESE SESSION:
|
||||
{angles_text}
|
||||
|
||||
⛔ BEREITS BEHANDELTE THEMEN - NICHT NOCHMAL VORSCHLAGEN:
|
||||
{existing_text}
|
||||
|
||||
=== DEINE AUFGABE ===
|
||||
|
||||
Recherchiere FAKTEN, DATEN und ENTWICKLUNGEN - keine fertigen Themenvorschläge!
|
||||
Ich brauche ROHDATEN die ich dann in personalisierte Content-Ideen umwandeln kann.
|
||||
|
||||
Für jede Entwicklung/News sammle:
|
||||
1. **Was genau ist passiert?** - Konkrete Fakten, nicht Interpretationen
|
||||
2. **Zahlen & Daten** - Statistiken, Prozentsätze, Beträge, Veränderungen
|
||||
3. **Wer ist beteiligt?** - Unternehmen, Personen, Organisationen
|
||||
4. **Wann?** - Genaues Datum oder Zeitraum
|
||||
5. **Quelle** - URL oder Publikationsname
|
||||
6. **Kontext** - Warum ist das relevant? Was bedeutet es?
|
||||
|
||||
SUCHE NACH:
|
||||
✅ Neue Studien/Reports mit konkreten Zahlen
|
||||
✅ Unternehmens-Entscheidungen oder -Ankündigungen
|
||||
✅ Marktveränderungen mit Daten
|
||||
✅ Gesetzliche/Regulatorische Änderungen
|
||||
✅ Kontroverse Aussagen von Branchenführern
|
||||
✅ Überraschende Statistiken oder Trends
|
||||
✅ Gescheiterte Projekte oder unerwartete Erfolge
|
||||
|
||||
FORMAT DEINER ANTWORT:
|
||||
Liefere 8-10 verschiedene Entwicklungen/News mit möglichst vielen Fakten und Zahlen.
|
||||
Formatiere sie klar und strukturiert.
|
||||
|
||||
QUALITÄTSKRITERIEN:
|
||||
✅ AKTUALITÄT: Von dieser Woche oder letzter Woche
|
||||
✅ KONKRETHEIT: Echte Zahlen, Namen, Daten (nicht "Experten sagen...")
|
||||
✅ VERIFIZIERBARKEIT: Echte Quelle die man prüfen kann
|
||||
✅ BRANCHENRELEVANZ: Spezifisch für {industry}
|
||||
|
||||
❌ VERMEIDE:
|
||||
- Vage Aussagen ohne Daten ("KI wird wichtiger")
|
||||
- Generische Trends ohne konkreten Aufhänger
|
||||
- Alte News die jeder schon kennt
|
||||
- Themen ohne verifizierbare Fakten"""
|
||||
|
||||
def _get_transform_prompt(
|
||||
self,
|
||||
raw_research: str,
|
||||
target_audience: str,
|
||||
persona: str,
|
||||
content_pillars: List[str],
|
||||
example_posts: List[str],
|
||||
existing_topics: List[str],
|
||||
post_type: Any = None,
|
||||
post_type_analysis: Dict[str, Any] = None
|
||||
) -> str:
|
||||
"""Transform raw research into personalized, concrete topic suggestions."""
|
||||
|
||||
# Build example posts section
|
||||
examples_section = ""
|
||||
if example_posts:
|
||||
examples_section = "\n\n=== SO SCHREIBT DIESE PERSON (Beispiel-Posts) ===\n"
|
||||
for i, post in enumerate(example_posts[:5], 1):
|
||||
post_preview = post[:600] + "..." if len(post) > 600 else post
|
||||
examples_section += f"\n--- Beispiel {i} ---\n{post_preview}\n"
|
||||
examples_section += "--- Ende Beispiele ---\n"
|
||||
|
||||
# Build pillars section
|
||||
pillars_text = ", ".join(content_pillars[:5]) if content_pillars else "Keine spezifischen Säulen"
|
||||
|
||||
# Build existing topics section (to avoid)
|
||||
existing_text = ", ".join(existing_topics[:15]) if existing_topics else "Keine"
|
||||
|
||||
# Build post type context section
|
||||
post_type_section = ""
|
||||
if post_type:
|
||||
post_type_section = f"""
|
||||
|
||||
=== ZIEL-POST-TYP: {post_type.name} ===
|
||||
{f"Beschreibung: {post_type.description}" if post_type.description else ""}
|
||||
{f"Typische Hashtags: {', '.join(post_type.identifying_hashtags[:5])}" if post_type.identifying_hashtags else ""}
|
||||
{f"Keywords: {', '.join(post_type.identifying_keywords[:10])}" if post_type.identifying_keywords else ""}
|
||||
"""
|
||||
if post_type.semantic_properties:
|
||||
props = post_type.semantic_properties
|
||||
if props.get("purpose"):
|
||||
post_type_section += f"Zweck: {props['purpose']}\n"
|
||||
if props.get("typical_tone"):
|
||||
post_type_section += f"Tonalität: {props['typical_tone']}\n"
|
||||
if props.get("target_audience"):
|
||||
post_type_section += f"Zielgruppe: {props['target_audience']}\n"
|
||||
|
||||
if post_type_analysis and post_type_analysis.get("sufficient_data"):
|
||||
post_type_section += "\n**Analyse-basierte Anforderungen:**\n"
|
||||
if hooks := post_type_analysis.get("hooks"):
|
||||
post_type_section += f"- Hook-Typen: {', '.join(hooks.get('hook_types', [])[:3])}\n"
|
||||
if content := post_type_analysis.get("content_focus"):
|
||||
post_type_section += f"- Hauptthemen: {', '.join(content.get('main_themes', [])[:3])}\n"
|
||||
if content.get("target_emotion"):
|
||||
post_type_section += f"- Ziel-Emotion: {content['target_emotion']}\n"
|
||||
|
||||
post_type_section += "\n**WICHTIG:** Alle Themenvorschläge müssen zu diesem Post-Typ passen!\n"
|
||||
|
||||
return f"""AUFGABE: Transformiere die Recherche-Ergebnisse in KONKRETE, PERSONALISIERTE Themenvorschläge.
|
||||
{post_type_section}
|
||||
|
||||
=== RECHERCHE-ERGEBNISSE (Rohdaten) ===
|
||||
{raw_research}
|
||||
|
||||
=== PERSON/EXPERTISE ===
|
||||
{persona[:800] if persona else "Keine Persona definiert"}
|
||||
|
||||
=== CONTENT-SÄULEN DER PERSON ===
|
||||
{pillars_text}
|
||||
{examples_section}
|
||||
=== BEREITS BEHANDELT (NICHT NOCHMAL!) ===
|
||||
{existing_text}
|
||||
|
||||
=== DEINE AUFGABE ===
|
||||
|
||||
Erstelle 6-8 KONKRETE Themenvorschläge die:
|
||||
1. ZU DIESER PERSON PASSEN - Basierend auf Expertise und Beispiel-Posts
|
||||
2. EINEN KLAREN ANGLE HABEN - Nicht "über X schreiben" sondern eine spezifische Perspektive
|
||||
3. FAKTEN AUS DER RECHERCHE NUTZEN - Konkrete Daten/Zahlen einbauen
|
||||
4. ABWECHSLUNGSREICH SIND - Verschiedene Kategorien und Formate
|
||||
|
||||
KATEGORIEN (mindestens 3 verschiedene!):
|
||||
- **Meinung/Take**: Deine Perspektive zu einem aktuellen Thema
|
||||
- **Erfahrungsbericht**: "Was ich gelernt habe als..."
|
||||
- **Konträr**: "Unpopuläre Meinung: ..."
|
||||
- **How-To/Insight**: Konkrete Tipps basierend auf Daten
|
||||
- **Story**: Persönliche Geschichte mit Business-Lesson
|
||||
- **Analyse**: Daten/Trend analysiert durch deine Expertise-Brille
|
||||
|
||||
FORMAT DER THEMENVORSCHLÄGE:
|
||||
|
||||
{{
|
||||
"topics": [
|
||||
{{
|
||||
"title": "Konkreter Thementitel (kein Schlagzeilen-Stil!)",
|
||||
"category": "Meinung/Take | Erfahrungsbericht | Konträr | How-To/Insight | Story | Analyse",
|
||||
"angle": "Der spezifische Blickwinkel/die Perspektive für diesen Post",
|
||||
"hook_idea": "Konkrete Hook-Idee die zum Post passen würde (1-2 Sätze)",
|
||||
"key_facts": ["Fakt 1 aus der Recherche", "Fakt 2 mit Zahlen", "Fakt 3"],
|
||||
"why_this_person": "Warum passt dieses Thema zu DIESER Person und ihrer Expertise?",
|
||||
"source": "Quellenangabe"
|
||||
}}
|
||||
]
|
||||
}}
|
||||
|
||||
BEISPIEL EINES GUTEN THEMENVORSCHLAGS:
|
||||
{{
|
||||
"title": "Warum ich als Tech-Lead jetzt 30% meiner Zeit mit Prompt Engineering verbringe",
|
||||
"category": "Erfahrungsbericht",
|
||||
"angle": "Persönliche Erfahrung eines Tech-Leads mit der Veränderung seiner Rolle durch KI",
|
||||
"hook_idea": "Vor einem Jahr habe ich Code geschrieben. Heute schreibe ich Prompts. Und ehrlich? Ich weiß noch nicht ob das gut oder schlecht ist.",
|
||||
"key_facts": ["GitHub Copilot wird von 92% der Entwickler genutzt (Stack Overflow 2024)", "Durchschnittliche Zeitersparnis: 55%", "Aber: Code-Review-Zeit +40%"],
|
||||
"why_this_person": "Als Tech-Lead hat die Person direkten Einblick in diese Veränderung und kann authentisch darüber berichten",
|
||||
"source": "Stack Overflow Developer Survey 2024"
|
||||
}}
|
||||
|
||||
WICHTIG:
|
||||
- Jeder Vorschlag muss sich UNTERSCHEIDEN (anderer Angle, andere Kategorie)
|
||||
- Keine generischen "Die Zukunft von X" Themen
|
||||
- Hook-Ideen müssen zum Stil der Beispiel-Posts passen!
|
||||
- Key Facts müssen aus der Recherche stammen (keine erfundenen Zahlen)"""
|
||||
|
||||
def _get_structure_prompt(
|
||||
self,
|
||||
raw_research: str,
|
||||
target_audience: str,
|
||||
persona: str = ""
|
||||
) -> str:
|
||||
"""Get prompt to structure Perplexity research into JSON (legacy)."""
|
||||
return f"""Strukturiere die folgenden Recherche-Ergebnisse in ein sauberes JSON-Format.
|
||||
|
||||
RECHERCHE-ERGEBNISSE:
|
||||
{raw_research}
|
||||
|
||||
AUFGABE:
|
||||
Extrahiere die Themen und formatiere sie als JSON. Behalte ALLE Fakten, Quellen und Details bei.
|
||||
|
||||
Gib das Ergebnis in diesem Format zurück:
|
||||
|
||||
{{
|
||||
"topics": [
|
||||
{{
|
||||
"title": "Prägnanter Titel des Themas",
|
||||
"category": "News-Flash / Schmerzpunkt-Löser / Konträrer Trend / Emerging Topic",
|
||||
"fact": "Die kompletten Fakten, Zahlen und Details aus der Recherche - NICHTS weglassen!",
|
||||
"relevance": "Warum ist das für {target_audience} wichtig?",
|
||||
"source": "Quellenangaben aus der Recherche"
|
||||
}}
|
||||
]
|
||||
}}
|
||||
|
||||
WICHTIG:
|
||||
- Behalte ALLE Fakten und Quellen aus der Recherche
|
||||
- Erfinde NICHTS dazu
|
||||
- Wenn etwas unklar ist, lass es weg
|
||||
- Mindestens 5 Themen wenn vorhanden"""
|
||||
|
||||
def _ensure_diversity(self, topics: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Ensure topic suggestions are diverse (different categories, angles).
|
||||
|
||||
Args:
|
||||
topics: List of topic suggestions
|
||||
|
||||
Returns:
|
||||
Filtered list with diverse topics
|
||||
"""
|
||||
if len(topics) <= 3:
|
||||
return topics
|
||||
|
||||
# Track categories used
|
||||
category_counts = {}
|
||||
diverse_topics = []
|
||||
|
||||
for topic in topics:
|
||||
category = topic.get("category", "Unknown")
|
||||
|
||||
# Allow max 2 topics per category
|
||||
if category_counts.get(category, 0) < 2:
|
||||
diverse_topics.append(topic)
|
||||
category_counts[category] = category_counts.get(category, 0) + 1
|
||||
|
||||
# If we filtered too many, add back some
|
||||
if len(diverse_topics) < 5 and len(topics) >= 5:
|
||||
for topic in topics:
|
||||
if topic not in diverse_topics:
|
||||
diverse_topics.append(topic)
|
||||
if len(diverse_topics) >= 6:
|
||||
break
|
||||
|
||||
logger.info(f"Diversity check: {len(topics)} -> {len(diverse_topics)} topics, categories: {category_counts}")
|
||||
return diverse_topics
|
||||
|
||||
def _extract_topics_from_response(self, response: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Extract structured topics from Perplexity response.
|
||||
|
||||
Args:
|
||||
response: Raw response from Perplexity
|
||||
|
||||
Returns:
|
||||
List of structured topic dictionaries
|
||||
"""
|
||||
topics = []
|
||||
|
||||
# Simple parsing - split by topic markers
|
||||
sections = response.split("[TITEL]:")
|
||||
|
||||
for section in sections[1:]: # Skip first empty section
|
||||
try:
|
||||
# Extract title
|
||||
title_end = section.find("[KATEGORIE]:")
|
||||
if title_end == -1:
|
||||
title_end = section.find("\n")
|
||||
title = section[:title_end].strip()
|
||||
|
||||
# Extract category
|
||||
category = ""
|
||||
if "[KATEGORIE]:" in section:
|
||||
cat_start = section.find("[KATEGORIE]:") + len("[KATEGORIE]:")
|
||||
cat_end = section.find("[DER FAKT]:")
|
||||
if cat_end == -1:
|
||||
cat_end = section.find("\n", cat_start)
|
||||
category = section[cat_start:cat_end].strip()
|
||||
|
||||
# Extract fact
|
||||
fact = ""
|
||||
if "[DER FAKT]:" in section:
|
||||
fact_start = section.find("[DER FAKT]:") + len("[DER FAKT]:")
|
||||
fact_end = section.find("[WARUM RELEVANT]:")
|
||||
if fact_end == -1:
|
||||
fact_end = section.find("[QUELLE]:")
|
||||
if fact_end == -1:
|
||||
fact_end = len(section)
|
||||
fact = section[fact_start:fact_end].strip()
|
||||
|
||||
# Extract relevance
|
||||
relevance = ""
|
||||
if "[WARUM RELEVANT]:" in section:
|
||||
rel_start = section.find("[WARUM RELEVANT]:") + len("[WARUM RELEVANT]:")
|
||||
rel_end = section.find("[QUELLE]:")
|
||||
if rel_end == -1:
|
||||
rel_end = len(section)
|
||||
relevance = section[rel_start:rel_end].strip()
|
||||
|
||||
if title and fact:
|
||||
topics.append({
|
||||
"title": title,
|
||||
"category": category or "Allgemein",
|
||||
"fact": fact,
|
||||
"relevance": relevance,
|
||||
"source": "perplexity_research"
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to parse topic section: {e}")
|
||||
continue
|
||||
|
||||
return topics
|
||||
129
src/agents/topic_extractor.py
Normal file
129
src/agents/topic_extractor.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""Topic extractor agent."""
|
||||
import json
|
||||
from typing import List, Dict, Any
|
||||
from loguru import logger
|
||||
|
||||
from src.agents.base import BaseAgent
|
||||
from src.database.models import LinkedInPost, Topic
|
||||
|
||||
|
||||
class TopicExtractorAgent(BaseAgent):
|
||||
"""Agent for extracting topics from LinkedIn posts."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize topic extractor agent."""
|
||||
super().__init__("TopicExtractor")
|
||||
|
||||
async def process(self, posts: List[LinkedInPost], customer_id) -> List[Topic]:
|
||||
"""
|
||||
Extract topics from LinkedIn posts.
|
||||
|
||||
Args:
|
||||
posts: List of LinkedIn posts
|
||||
customer_id: Customer UUID (as UUID or string)
|
||||
|
||||
Returns:
|
||||
List of extracted topics
|
||||
"""
|
||||
logger.info(f"Extracting topics from {len(posts)} posts")
|
||||
|
||||
# Prepare posts for analysis
|
||||
posts_data = []
|
||||
for idx, post in enumerate(posts[:30]): # Analyze up to 30 posts
|
||||
posts_data.append({
|
||||
"index": idx,
|
||||
"post_id": str(post.id) if post.id else None,
|
||||
"text": post.post_text[:500], # Limit text length
|
||||
"date": str(post.post_date) if post.post_date else None
|
||||
})
|
||||
|
||||
system_prompt = self._get_system_prompt()
|
||||
user_prompt = self._get_user_prompt(posts_data)
|
||||
|
||||
response = await self.call_openai(
|
||||
system_prompt=system_prompt,
|
||||
user_prompt=user_prompt,
|
||||
model="gpt-4o",
|
||||
temperature=0.3,
|
||||
response_format={"type": "json_object"}
|
||||
)
|
||||
|
||||
# Parse response
|
||||
result = json.loads(response)
|
||||
topics_data = result.get("topics", [])
|
||||
|
||||
# Create Topic objects
|
||||
topics = []
|
||||
for topic_data in topics_data:
|
||||
# Get post index from topic_data if available
|
||||
post_index = topic_data.get("post_id")
|
||||
extracted_from_post_id = None
|
||||
|
||||
# Map post index to actual post ID
|
||||
if post_index is not None and isinstance(post_index, (int, str)):
|
||||
try:
|
||||
# Convert to int if it's a string representation
|
||||
idx = int(post_index) if isinstance(post_index, str) else post_index
|
||||
# Get the actual post from the posts list
|
||||
if 0 <= idx < len(posts) and posts[idx].id:
|
||||
extracted_from_post_id = posts[idx].id
|
||||
except (ValueError, IndexError):
|
||||
logger.warning(f"Could not map post index {post_index} to post ID")
|
||||
|
||||
topic = Topic(
|
||||
customer_id=customer_id, # Will be handled by Pydantic
|
||||
title=topic_data["title"],
|
||||
description=topic_data.get("description"),
|
||||
category=topic_data.get("category"),
|
||||
extracted_from_post_id=extracted_from_post_id,
|
||||
extraction_confidence=topic_data.get("confidence", 0.8)
|
||||
)
|
||||
topics.append(topic)
|
||||
|
||||
logger.info(f"Extracted {len(topics)} topics")
|
||||
return topics
|
||||
|
||||
def _get_system_prompt(self) -> str:
|
||||
"""Get system prompt for topic extraction."""
|
||||
return """Du bist ein AI-Experte für Themenanalyse und Content-Kategorisierung.
|
||||
|
||||
Deine Aufgabe ist es, aus einer Liste von LinkedIn-Posts die Hauptthemen zu extrahieren.
|
||||
|
||||
Für jedes identifizierte Thema sollst du:
|
||||
1. Ein prägnantes Titel geben
|
||||
2. Eine kurze Beschreibung verfassen
|
||||
3. Eine Kategorie zuweisen (z.B. "Technologie", "Strategie", "Personal Development", etc.)
|
||||
4. Die Konfidenz angeben (0.0 - 1.0)
|
||||
|
||||
Wichtig:
|
||||
- Fasse ähnliche Themen zusammen (z.B. "KI im Marketing" und "AI-Tools" → "KI & Automatisierung")
|
||||
- Identifiziere übergeordnete Themen-Cluster
|
||||
- Sei präzise und konkret
|
||||
- Vermeide zu allgemeine Themen wie "Business" oder "Erfolg"
|
||||
|
||||
Gib deine Antwort als JSON zurück."""
|
||||
|
||||
def _get_user_prompt(self, posts_data: List[Dict[str, Any]]) -> str:
|
||||
"""Get user prompt with posts data."""
|
||||
posts_text = json.dumps(posts_data, indent=2, ensure_ascii=False)
|
||||
|
||||
return f"""Analysiere folgende LinkedIn-Posts und extrahiere die Hauptthemen:
|
||||
|
||||
{posts_text}
|
||||
|
||||
Gib deine Analyse im folgenden JSON-Format zurück:
|
||||
|
||||
{{
|
||||
"topics": [
|
||||
{{
|
||||
"title": "Thementitel",
|
||||
"description": "Kurze Beschreibung des Themas",
|
||||
"category": "Kategorie",
|
||||
"post_id": "ID des repräsentativen Posts (optional)",
|
||||
"confidence": 0.9,
|
||||
"frequency": "Wie oft kommt das Thema vor?"
|
||||
}}
|
||||
]
|
||||
}}
|
||||
|
||||
Extrahiere 5-10 Hauptthemen."""
|
||||
764
src/agents/writer.py
Normal file
764
src/agents/writer.py
Normal file
@@ -0,0 +1,764 @@
|
||||
"""Writer agent for creating LinkedIn posts."""
|
||||
import asyncio
|
||||
import json
|
||||
import random
|
||||
import re
|
||||
from typing import Dict, Any, Optional, List
|
||||
from loguru import logger
|
||||
|
||||
from src.agents.base import BaseAgent
|
||||
from src.config import settings
|
||||
|
||||
|
||||
class WriterAgent(BaseAgent):
|
||||
"""Agent for writing LinkedIn posts based on profile analysis."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize writer agent."""
|
||||
super().__init__("Writer")
|
||||
|
||||
async def process(
|
||||
self,
|
||||
topic: Dict[str, Any],
|
||||
profile_analysis: Dict[str, Any],
|
||||
feedback: Optional[str] = None,
|
||||
previous_version: Optional[str] = None,
|
||||
example_posts: Optional[List[str]] = None,
|
||||
critic_result: Optional[Dict[str, Any]] = None,
|
||||
learned_lessons: Optional[Dict[str, Any]] = None,
|
||||
post_type: Any = None,
|
||||
post_type_analysis: Optional[Dict[str, Any]] = None
|
||||
) -> str:
|
||||
"""
|
||||
Write a LinkedIn post.
|
||||
|
||||
Args:
|
||||
topic: Topic dictionary with title, fact, relevance
|
||||
profile_analysis: Profile analysis results
|
||||
feedback: Optional feedback from critic (text summary)
|
||||
previous_version: Optional previous version of the post
|
||||
example_posts: Optional list of real posts from the customer to use as style reference
|
||||
critic_result: Optional full critic result with specific_changes
|
||||
learned_lessons: Optional lessons learned from past critic feedback
|
||||
post_type: Optional PostType object for type-specific writing
|
||||
post_type_analysis: Optional analysis of the post type
|
||||
|
||||
Returns:
|
||||
Written LinkedIn post
|
||||
"""
|
||||
if feedback and previous_version:
|
||||
logger.info(f"Revising post based on critic feedback")
|
||||
# For revisions, always use single draft (feedback is specific)
|
||||
return await self._write_single_draft(
|
||||
topic=topic,
|
||||
profile_analysis=profile_analysis,
|
||||
feedback=feedback,
|
||||
previous_version=previous_version,
|
||||
example_posts=example_posts,
|
||||
critic_result=critic_result,
|
||||
learned_lessons=learned_lessons,
|
||||
post_type=post_type,
|
||||
post_type_analysis=post_type_analysis
|
||||
)
|
||||
else:
|
||||
logger.info(f"Writing initial post for topic: {topic.get('title', 'Unknown')}")
|
||||
if post_type:
|
||||
logger.info(f"Using post type: {post_type.name}")
|
||||
|
||||
# Select example posts - use semantic matching if enabled
|
||||
selected_examples = self._select_example_posts(topic, example_posts, profile_analysis)
|
||||
|
||||
# Use Multi-Draft if enabled for initial posts
|
||||
if settings.writer_multi_draft_enabled:
|
||||
return await self._write_multi_draft(
|
||||
topic=topic,
|
||||
profile_analysis=profile_analysis,
|
||||
example_posts=selected_examples,
|
||||
learned_lessons=learned_lessons,
|
||||
post_type=post_type,
|
||||
post_type_analysis=post_type_analysis
|
||||
)
|
||||
else:
|
||||
return await self._write_single_draft(
|
||||
topic=topic,
|
||||
profile_analysis=profile_analysis,
|
||||
example_posts=selected_examples,
|
||||
learned_lessons=learned_lessons,
|
||||
post_type=post_type,
|
||||
post_type_analysis=post_type_analysis
|
||||
)
|
||||
|
||||
def _select_example_posts(
|
||||
self,
|
||||
topic: Dict[str, Any],
|
||||
example_posts: Optional[List[str]],
|
||||
profile_analysis: Dict[str, Any]
|
||||
) -> List[str]:
|
||||
"""
|
||||
Select example posts - either semantically similar or random.
|
||||
|
||||
Args:
|
||||
topic: The topic to write about
|
||||
example_posts: All available example posts
|
||||
profile_analysis: Profile analysis results
|
||||
|
||||
Returns:
|
||||
Selected example posts (3-4 posts)
|
||||
"""
|
||||
if not example_posts or len(example_posts) == 0:
|
||||
return []
|
||||
|
||||
if not settings.writer_semantic_matching_enabled:
|
||||
# Fallback to random selection
|
||||
num_examples = min(3, len(example_posts))
|
||||
selected = random.sample(example_posts, num_examples)
|
||||
logger.info(f"Using {len(selected)} random example posts")
|
||||
return selected
|
||||
|
||||
# Semantic matching based on keywords
|
||||
logger.info("Using semantic matching for example post selection")
|
||||
|
||||
# Extract keywords from topic
|
||||
topic_text = f"{topic.get('title', '')} {topic.get('fact', '')} {topic.get('category', '')}".lower()
|
||||
topic_keywords = self._extract_keywords(topic_text)
|
||||
|
||||
# Score each post by keyword overlap
|
||||
scored_posts = []
|
||||
for post in example_posts:
|
||||
post_lower = post.lower()
|
||||
score = 0
|
||||
matched_keywords = []
|
||||
|
||||
for keyword in topic_keywords:
|
||||
if keyword in post_lower:
|
||||
score += 1
|
||||
matched_keywords.append(keyword)
|
||||
|
||||
# Bonus for longer matches
|
||||
score += len(matched_keywords) * 0.5
|
||||
|
||||
scored_posts.append({
|
||||
"post": post,
|
||||
"score": score,
|
||||
"matched": matched_keywords
|
||||
})
|
||||
|
||||
# Sort by score (highest first)
|
||||
scored_posts.sort(key=lambda x: x["score"], reverse=True)
|
||||
|
||||
# Take top 2 by relevance + 1 random (for variety)
|
||||
selected = []
|
||||
|
||||
# Top 2 most relevant
|
||||
for item in scored_posts[:2]:
|
||||
if item["score"] > 0:
|
||||
selected.append(item["post"])
|
||||
logger.debug(f"Selected post (score {item['score']:.1f}, keywords: {item['matched'][:3]})")
|
||||
|
||||
# Add 1 random post for variety (if not already selected)
|
||||
remaining_posts = [p["post"] for p in scored_posts[2:] if p["post"] not in selected]
|
||||
if remaining_posts and len(selected) < 3:
|
||||
random_pick = random.choice(remaining_posts)
|
||||
selected.append(random_pick)
|
||||
logger.debug("Added 1 random post for variety")
|
||||
|
||||
# If we still don't have enough, fill with top scored
|
||||
while len(selected) < 3 and len(selected) < len(example_posts):
|
||||
for item in scored_posts:
|
||||
if item["post"] not in selected:
|
||||
selected.append(item["post"])
|
||||
break
|
||||
|
||||
logger.info(f"Selected {len(selected)} example posts via semantic matching")
|
||||
return selected
|
||||
|
||||
def _extract_keywords(self, text: str) -> List[str]:
|
||||
"""Extract meaningful keywords from text."""
|
||||
# Remove common stop words
|
||||
stop_words = {
|
||||
'der', 'die', 'das', 'und', 'in', 'zu', 'den', 'von', 'für', 'mit',
|
||||
'auf', 'ist', 'im', 'sich', 'des', 'ein', 'eine', 'als', 'auch',
|
||||
'es', 'an', 'werden', 'aus', 'er', 'hat', 'dass', 'sie', 'nach',
|
||||
'wird', 'bei', 'einer', 'um', 'am', 'sind', 'noch', 'wie', 'einem',
|
||||
'über', 'so', 'zum', 'kann', 'nur', 'sein', 'ich', 'nicht', 'was',
|
||||
'oder', 'aber', 'wenn', 'ihre', 'man', 'the', 'and', 'to', 'of',
|
||||
'a', 'is', 'that', 'it', 'for', 'on', 'are', 'with', 'be', 'this',
|
||||
'was', 'have', 'from', 'your', 'you', 'we', 'our', 'mehr', 'neue',
|
||||
'neuen', 'können', 'durch', 'diese', 'dieser', 'einem', 'einen'
|
||||
}
|
||||
|
||||
# Split and clean
|
||||
words = re.findall(r'\b[a-zäöüß]{3,}\b', text.lower())
|
||||
keywords = [w for w in words if w not in stop_words and len(w) >= 4]
|
||||
|
||||
# Also extract compound words and important terms
|
||||
important_terms = re.findall(r'\b[A-Z][a-zäöüß]+(?:[A-Z][a-zäöüß]+)*\b', text)
|
||||
keywords.extend([t.lower() for t in important_terms if len(t) >= 4])
|
||||
|
||||
# Deduplicate while preserving order
|
||||
seen = set()
|
||||
unique_keywords = []
|
||||
for kw in keywords:
|
||||
if kw not in seen:
|
||||
seen.add(kw)
|
||||
unique_keywords.append(kw)
|
||||
|
||||
return unique_keywords[:15] # Limit to top 15 keywords
|
||||
|
||||
async def _write_multi_draft(
|
||||
self,
|
||||
topic: Dict[str, Any],
|
||||
profile_analysis: Dict[str, Any],
|
||||
example_posts: List[str],
|
||||
learned_lessons: Optional[Dict[str, Any]] = None,
|
||||
post_type: Any = None,
|
||||
post_type_analysis: Optional[Dict[str, Any]] = None
|
||||
) -> str:
|
||||
"""
|
||||
Generate multiple drafts and select the best one.
|
||||
|
||||
Args:
|
||||
topic: Topic to write about
|
||||
profile_analysis: Profile analysis results
|
||||
example_posts: Example posts for style reference
|
||||
learned_lessons: Lessons learned from past feedback
|
||||
post_type: Optional PostType object
|
||||
post_type_analysis: Optional post type analysis
|
||||
|
||||
Returns:
|
||||
Best selected draft
|
||||
"""
|
||||
num_drafts = min(max(settings.writer_multi_draft_count, 2), 5) # Clamp between 2-5
|
||||
logger.info(f"Generating {num_drafts} drafts for selection")
|
||||
|
||||
system_prompt = self._get_system_prompt(profile_analysis, example_posts, learned_lessons, post_type, post_type_analysis)
|
||||
|
||||
# Generate drafts in parallel with different temperatures/approaches
|
||||
draft_configs = [
|
||||
{"temperature": 0.5, "approach": "fokussiert"},
|
||||
{"temperature": 0.7, "approach": "kreativ"},
|
||||
{"temperature": 0.6, "approach": "ausgewogen"},
|
||||
{"temperature": 0.8, "approach": "experimentell"},
|
||||
{"temperature": 0.55, "approach": "präzise"},
|
||||
][:num_drafts]
|
||||
|
||||
# Create draft tasks
|
||||
async def generate_draft(config: Dict, draft_num: int) -> Dict[str, Any]:
|
||||
user_prompt = self._get_user_prompt_for_draft(topic, draft_num, config["approach"])
|
||||
try:
|
||||
draft = await self.call_openai(
|
||||
system_prompt=system_prompt,
|
||||
user_prompt=user_prompt,
|
||||
model="gpt-4o",
|
||||
temperature=config["temperature"]
|
||||
)
|
||||
return {
|
||||
"draft_num": draft_num,
|
||||
"content": draft.strip(),
|
||||
"approach": config["approach"],
|
||||
"temperature": config["temperature"]
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to generate draft {draft_num}: {e}")
|
||||
return None
|
||||
|
||||
# Run drafts in parallel
|
||||
tasks = [generate_draft(config, i + 1) for i, config in enumerate(draft_configs)]
|
||||
results = await asyncio.gather(*tasks)
|
||||
|
||||
# Filter out failed drafts
|
||||
drafts = [r for r in results if r is not None]
|
||||
|
||||
if not drafts:
|
||||
raise ValueError("All draft generations failed")
|
||||
|
||||
if len(drafts) == 1:
|
||||
logger.warning("Only one draft succeeded, using it directly")
|
||||
return drafts[0]["content"]
|
||||
|
||||
logger.info(f"Generated {len(drafts)} drafts, now selecting best one")
|
||||
|
||||
# Select the best draft
|
||||
best_draft = await self._select_best_draft(drafts, topic, profile_analysis)
|
||||
return best_draft
|
||||
|
||||
def _get_user_prompt_for_draft(
|
||||
self,
|
||||
topic: Dict[str, Any],
|
||||
draft_num: int,
|
||||
approach: str
|
||||
) -> str:
|
||||
"""Get user prompt with slight variations for different drafts."""
|
||||
# Different emphasis for each draft
|
||||
emphasis_variations = {
|
||||
1: "Fokussiere auf einen STARKEN, überraschenden Hook. Der erste Satz muss fesseln!",
|
||||
2: "Fokussiere auf STORYTELLING. Baue eine kleine Geschichte oder Anekdote ein.",
|
||||
3: "Fokussiere auf KONKRETEN MEHRWERT. Was lernt der Leser konkret?",
|
||||
4: "Fokussiere auf EMOTION. Sprich Gefühle und persönliche Erfahrungen an.",
|
||||
5: "Fokussiere auf PROVOKATION. Stelle eine These auf, die zum Nachdenken anregt.",
|
||||
}
|
||||
|
||||
emphasis = emphasis_variations.get(draft_num, emphasis_variations[1])
|
||||
|
||||
# Build enhanced topic section with new fields
|
||||
angle_section = ""
|
||||
if topic.get('angle'):
|
||||
angle_section = f"\n**ANGLE/PERSPEKTIVE:**\n{topic.get('angle')}\n"
|
||||
|
||||
hook_section = ""
|
||||
if topic.get('hook_idea'):
|
||||
hook_section = f"\n**HOOK-IDEE (als Inspiration):**\n\"{topic.get('hook_idea')}\"\n"
|
||||
|
||||
facts_section = ""
|
||||
key_facts = topic.get('key_facts', [])
|
||||
if key_facts and isinstance(key_facts, list) and len(key_facts) > 0:
|
||||
facts_section = "\n**KEY FACTS (nutze diese!):**\n" + "\n".join([f"- {f}" for f in key_facts]) + "\n"
|
||||
|
||||
why_section = ""
|
||||
if topic.get('why_this_person'):
|
||||
why_section = f"\n**WARUM DU DARÜBER SCHREIBEN SOLLTEST:**\n{topic.get('why_this_person')}\n"
|
||||
|
||||
return f"""Schreibe einen LinkedIn-Post zu folgendem Thema:
|
||||
|
||||
**THEMA:** {topic.get('title', 'Unbekanntes Thema')}
|
||||
|
||||
**KATEGORIE:** {topic.get('category', 'Allgemein')}
|
||||
{angle_section}{hook_section}
|
||||
**KERN-FAKT / INHALT:**
|
||||
{topic.get('fact', topic.get('description', ''))}
|
||||
{facts_section}
|
||||
**WARUM RELEVANT:**
|
||||
{topic.get('relevance', 'Aktuelles Thema für die Zielgruppe')}
|
||||
{why_section}
|
||||
**DEIN ANSATZ FÜR DIESEN ENTWURF ({approach}):**
|
||||
{emphasis}
|
||||
|
||||
**AUFGABE:**
|
||||
Schreibe einen authentischen LinkedIn-Post, der:
|
||||
1. Mit einem STARKEN, unerwarteten Hook beginnt (nutze die Hook-Idee als Inspiration, NICHT wörtlich!)
|
||||
2. Den Fakt/das Thema aufgreift und Mehrwert bietet
|
||||
3. Die Key Facts einbaut wo es passt
|
||||
4. Eine persönliche Note oder Meinung enthält
|
||||
5. Mit einem passenden CTA endet
|
||||
|
||||
WICHTIG:
|
||||
- Vermeide KI-typische Formulierungen ("In der heutigen Zeit", "Tauchen Sie ein", etc.)
|
||||
- Schreibe natürlich und menschlich
|
||||
- Der Post soll SOFORT 85+ Punkte im Review erreichen
|
||||
- Die Hook-Idee ist nur INSPIRATION - mach etwas Eigenes daraus!
|
||||
|
||||
Gib NUR den fertigen Post zurück."""
|
||||
|
||||
async def _select_best_draft(
|
||||
self,
|
||||
drafts: List[Dict[str, Any]],
|
||||
topic: Dict[str, Any],
|
||||
profile_analysis: Dict[str, Any]
|
||||
) -> str:
|
||||
"""
|
||||
Use AI to select the best draft.
|
||||
|
||||
Args:
|
||||
drafts: List of draft dictionaries
|
||||
topic: The topic being written about
|
||||
profile_analysis: Profile analysis for style reference
|
||||
|
||||
Returns:
|
||||
Content of the best draft
|
||||
"""
|
||||
# Build comparison prompt
|
||||
drafts_text = ""
|
||||
for draft in drafts:
|
||||
drafts_text += f"\n\n=== ENTWURF {draft['draft_num']} ({draft['approach']}) ===\n"
|
||||
drafts_text += draft["content"]
|
||||
drafts_text += "\n=== ENDE ENTWURF ==="
|
||||
|
||||
# Extract key style elements for comparison
|
||||
writing_style = profile_analysis.get("writing_style", {})
|
||||
linguistic = profile_analysis.get("linguistic_fingerprint", {})
|
||||
phrase_library = profile_analysis.get("phrase_library", {})
|
||||
|
||||
selector_prompt = f"""Du bist ein erfahrener LinkedIn-Content-Editor. Wähle den BESTEN Entwurf aus.
|
||||
|
||||
**THEMA DES POSTS:**
|
||||
{topic.get('title', 'Unbekannt')}
|
||||
|
||||
**STIL-ANFORDERUNGEN:**
|
||||
- Tonalität: {writing_style.get('tone', 'Professionell')}
|
||||
- Energie-Level: {linguistic.get('energy_level', 7)}/10
|
||||
- Ansprache: {writing_style.get('form_of_address', 'Du')}
|
||||
- Typische Hook-Phrasen: {', '.join(phrase_library.get('hook_phrases', [])[:3])}
|
||||
|
||||
**DIE ENTWÜRFE:**
|
||||
{drafts_text}
|
||||
|
||||
**BEWERTUNGSKRITERIEN:**
|
||||
1. **Hook-Qualität (30%):** Wie aufmerksamkeitsstark ist der erste Satz?
|
||||
2. **Stil-Match (25%):** Wie gut passt der Entwurf zum beschriebenen Stil?
|
||||
3. **Mehrwert (25%):** Wie viel konkreten Nutzen bietet der Post?
|
||||
4. **Natürlichkeit (20%):** Wie authentisch und menschlich klingt er?
|
||||
|
||||
**AUFGABE:**
|
||||
Analysiere jeden Entwurf kurz und wähle den besten. Antworte im JSON-Format:
|
||||
|
||||
{{
|
||||
"analysis": [
|
||||
{{"draft": 1, "hook_score": 8, "style_score": 7, "value_score": 8, "natural_score": 7, "total": 30, "notes": "Kurze Begründung"}},
|
||||
...
|
||||
],
|
||||
"winner": 1,
|
||||
"reason": "Kurze Begründung für die Wahl"
|
||||
}}"""
|
||||
|
||||
response = await self.call_openai(
|
||||
system_prompt="Du bist ein Content-Editor, der LinkedIn-Posts bewertet und den besten auswählt.",
|
||||
user_prompt=selector_prompt,
|
||||
model="gpt-4o-mini", # Use cheaper model for selection
|
||||
temperature=0.2,
|
||||
response_format={"type": "json_object"}
|
||||
)
|
||||
|
||||
try:
|
||||
result = json.loads(response)
|
||||
winner_num = result.get("winner", 1)
|
||||
reason = result.get("reason", "")
|
||||
|
||||
# Find the winning draft
|
||||
winning_draft = next(
|
||||
(d for d in drafts if d["draft_num"] == winner_num),
|
||||
drafts[0] # Fallback to first draft
|
||||
)
|
||||
|
||||
logger.info(f"Selected draft {winner_num} ({winning_draft['approach']}): {reason}")
|
||||
return winning_draft["content"]
|
||||
|
||||
except (json.JSONDecodeError, KeyError) as e:
|
||||
logger.warning(f"Failed to parse selector response, using first draft: {e}")
|
||||
return drafts[0]["content"]
|
||||
|
||||
async def _write_single_draft(
|
||||
self,
|
||||
topic: Dict[str, Any],
|
||||
profile_analysis: Dict[str, Any],
|
||||
feedback: Optional[str] = None,
|
||||
previous_version: Optional[str] = None,
|
||||
example_posts: Optional[List[str]] = None,
|
||||
critic_result: Optional[Dict[str, Any]] = None,
|
||||
learned_lessons: Optional[Dict[str, Any]] = None,
|
||||
post_type: Any = None,
|
||||
post_type_analysis: Optional[Dict[str, Any]] = None
|
||||
) -> str:
|
||||
"""Write a single draft (original behavior)."""
|
||||
# Select examples if not already selected
|
||||
if example_posts is None:
|
||||
example_posts = []
|
||||
|
||||
selected_examples = example_posts
|
||||
if not feedback and not previous_version:
|
||||
# Only select for initial posts, not revisions
|
||||
if len(selected_examples) == 0:
|
||||
pass # No examples available
|
||||
elif len(selected_examples) > 3:
|
||||
selected_examples = random.sample(selected_examples, 3)
|
||||
|
||||
system_prompt = self._get_system_prompt(profile_analysis, selected_examples, learned_lessons, post_type, post_type_analysis)
|
||||
user_prompt = self._get_user_prompt(topic, feedback, previous_version, critic_result)
|
||||
|
||||
# Lower temperature for more consistent style matching
|
||||
post = await self.call_openai(
|
||||
system_prompt=system_prompt,
|
||||
user_prompt=user_prompt,
|
||||
model="gpt-4o",
|
||||
temperature=0.6
|
||||
)
|
||||
|
||||
logger.info("Post written successfully")
|
||||
return post.strip()
|
||||
|
||||
def _get_system_prompt(
|
||||
self,
|
||||
profile_analysis: Dict[str, Any],
|
||||
example_posts: List[str] = None,
|
||||
learned_lessons: Optional[Dict[str, Any]] = None,
|
||||
post_type: Any = None,
|
||||
post_type_analysis: Optional[Dict[str, Any]] = None
|
||||
) -> str:
|
||||
"""Get system prompt for writer - orientiert an bewährten n8n-Prompts."""
|
||||
# Extract key profile information
|
||||
writing_style = profile_analysis.get("writing_style", {})
|
||||
linguistic = profile_analysis.get("linguistic_fingerprint", {})
|
||||
tone_analysis = profile_analysis.get("tone_analysis", {})
|
||||
visual = profile_analysis.get("visual_patterns", {})
|
||||
content_strategy = profile_analysis.get("content_strategy", {})
|
||||
audience = profile_analysis.get("audience_insights", {})
|
||||
phrase_library = profile_analysis.get("phrase_library", {})
|
||||
structure_templates = profile_analysis.get("structure_templates", {})
|
||||
|
||||
# Build example posts section
|
||||
examples_section = ""
|
||||
if example_posts and len(example_posts) > 0:
|
||||
examples_section = "\n\nREFERENZ-POSTS DER PERSON (Orientiere dich am Stil!):\n"
|
||||
for i, post in enumerate(example_posts, 1):
|
||||
post_text = post[:1800] + "..." if len(post) > 1800 else post
|
||||
examples_section += f"\n--- Beispiel {i} ---\n{post_text}\n"
|
||||
examples_section += "--- Ende Beispiele ---\n"
|
||||
|
||||
# Safe extraction of nested values
|
||||
emoji_list = visual.get('emoji_usage', {}).get('emojis', ['🚀'])
|
||||
emoji_str = ' '.join(emoji_list) if isinstance(emoji_list, list) else str(emoji_list)
|
||||
sig_phrases = linguistic.get('signature_phrases', [])
|
||||
narrative_anchors = linguistic.get('narrative_anchors', [])
|
||||
narrative_str = ', '.join(narrative_anchors) if narrative_anchors else 'Storytelling'
|
||||
pain_points = audience.get('pain_points_addressed', [])
|
||||
pain_points_str = ', '.join(pain_points) if pain_points else 'Branchenspezifische Herausforderungen'
|
||||
|
||||
# Extract phrase library with variation instruction
|
||||
hook_phrases = phrase_library.get('hook_phrases', [])
|
||||
transition_phrases = phrase_library.get('transition_phrases', [])
|
||||
emotional_expressions = phrase_library.get('emotional_expressions', [])
|
||||
cta_phrases = phrase_library.get('cta_phrases', [])
|
||||
filler_expressions = phrase_library.get('filler_expressions', [])
|
||||
|
||||
# Randomly select a subset of phrases for this post (variation!)
|
||||
def select_phrases(phrases: list, max_count: int = 3) -> str:
|
||||
if not phrases:
|
||||
return "Keine verfügbar"
|
||||
selected = random.sample(phrases, min(max_count, len(phrases)))
|
||||
return '\n - '.join(selected)
|
||||
|
||||
# Extract structure templates
|
||||
primary_structure = structure_templates.get('primary_structure', 'Hook → Body → CTA')
|
||||
sentence_starters = structure_templates.get('typical_sentence_starters', [])
|
||||
paragraph_transitions = structure_templates.get('paragraph_transitions', [])
|
||||
|
||||
# Build phrase library section
|
||||
phrase_section = ""
|
||||
if hook_phrases or emotional_expressions or cta_phrases:
|
||||
phrase_section = f"""
|
||||
|
||||
2. PHRASEN-BIBLIOTHEK (Wähle passende aus - NICHT alle verwenden!):
|
||||
|
||||
HOOK-VORLAGEN (lass dich inspirieren, kopiere nicht 1:1):
|
||||
- {select_phrases(hook_phrases, 4)}
|
||||
|
||||
ÜBERGANGS-PHRASEN (nutze 1-2 davon):
|
||||
- {select_phrases(transition_phrases, 3)}
|
||||
|
||||
EMOTIONALE AUSDRÜCKE (nutze 1-2 passende):
|
||||
- {select_phrases(emotional_expressions, 4)}
|
||||
|
||||
CTA-FORMULIERUNGEN (wähle eine passende):
|
||||
- {select_phrases(cta_phrases, 3)}
|
||||
|
||||
FÜLL-AUSDRÜCKE (für natürlichen Flow):
|
||||
- {select_phrases(filler_expressions, 3)}
|
||||
|
||||
SIGNATURE PHRASES (nutze maximal 1-2 ORGANISCH):
|
||||
- {select_phrases(sig_phrases, 4)}
|
||||
|
||||
WICHTIG: Variiere! Nutze NICHT immer die gleichen Phrasen. Wähle die, die zum Thema passen.
|
||||
"""
|
||||
|
||||
# Build structure section
|
||||
structure_section = f"""
|
||||
|
||||
3. STRUKTUR-TEMPLATE:
|
||||
|
||||
Primäre Struktur: {primary_structure}
|
||||
|
||||
Typische Satzanfänge (nutze ähnliche):
|
||||
- {select_phrases(sentence_starters, 4)}
|
||||
|
||||
Absatz-Übergänge:
|
||||
- {select_phrases(paragraph_transitions, 3)}
|
||||
"""
|
||||
|
||||
# Build lessons learned section (from past feedback)
|
||||
lessons_section = ""
|
||||
if learned_lessons and learned_lessons.get("lessons"):
|
||||
lessons_section = "\n\n6. LESSONS LEARNED (aus vergangenen Posts - BEACHTE DIESE!):\n"
|
||||
patterns = learned_lessons.get("patterns", {})
|
||||
if patterns.get("posts_analyzed", 0) > 0:
|
||||
lessons_section += f"\n(Basierend auf {patterns.get('posts_analyzed', 0)} analysierten Posts, Durchschnittsscore: {patterns.get('avg_score', 0):.0f}/100)\n"
|
||||
|
||||
for lesson in learned_lessons["lessons"]:
|
||||
if lesson["type"] == "critical":
|
||||
lessons_section += f"\n⚠️ KRITISCH - {lesson['message']}\n"
|
||||
for item in lesson["items"]:
|
||||
lessons_section += f" ❌ {item}\n"
|
||||
elif lesson["type"] == "recurring":
|
||||
lessons_section += f"\n📝 {lesson['message']}\n"
|
||||
for item in lesson["items"]:
|
||||
lessons_section += f" • {item}\n"
|
||||
|
||||
lessons_section += "\nBerücksichtige diese Punkte PROAKTIV beim Schreiben!"
|
||||
|
||||
# Build post type section
|
||||
post_type_section = ""
|
||||
if post_type:
|
||||
post_type_section = f"""
|
||||
|
||||
7. POST-TYP SPEZIFISCH: {post_type.name}
|
||||
{f"Beschreibung: {post_type.description}" if post_type.description else ""}
|
||||
"""
|
||||
if post_type_analysis and post_type_analysis.get("sufficient_data"):
|
||||
# Use the PostTypeAnalyzerAgent's helper method to generate the section
|
||||
from src.agents.post_type_analyzer import PostTypeAnalyzerAgent
|
||||
analyzer = PostTypeAnalyzerAgent()
|
||||
type_guidelines = analyzer.get_writing_prompt_section(post_type_analysis)
|
||||
if type_guidelines:
|
||||
post_type_section += f"""
|
||||
=== POST-TYP ANALYSE & RICHTLINIEN ===
|
||||
{type_guidelines}
|
||||
=== ENDE POST-TYP RICHTLINIEN ===
|
||||
|
||||
WICHTIG: Dieser Post MUSS den Mustern und Richtlinien dieses Post-Typs folgen!
|
||||
"""
|
||||
|
||||
return f"""ROLLE: Du bist ein erstklassiger Ghostwriter für LinkedIn. Deine Aufgabe ist es, einen Post zu schreiben, der exakt so klingt wie der digitale Zwilling der beschriebenen Person. Du passt dich zu 100% an das bereitgestellte Profil an.
|
||||
{examples_section}
|
||||
|
||||
1. STIL & ENERGIE:
|
||||
|
||||
Energie-Level (1-10): {linguistic.get('energy_level', 7)}
|
||||
(WICHTIG: Passe die Intensität und Leidenschaft des Textes EXAKT an diesen Wert an. Bei 9-10 = hochemotional, bei 5-6 = sachlich-professionell)
|
||||
|
||||
Rhetorisches Shouting: {linguistic.get('shouting_usage', 'Dezent')}
|
||||
(Nutze GROSSBUCHSTABEN für einzelne Wörter genau so wie hier beschrieben, um Emphase zu erzeugen - mach das für KEINE anderen Wörter!)
|
||||
|
||||
Tonalität: {tone_analysis.get('primary_tone', 'Professionell und authentisch')}
|
||||
|
||||
Ansprache (STRENGSTENS EINHALTEN): {writing_style.get('form_of_address', 'Du/Euch')}
|
||||
|
||||
Perspektive (STRENGSTENS EINHALTEN): {writing_style.get('perspective', 'Ich-Perspektive')}
|
||||
|
||||
Satz-Dynamik: {writing_style.get('sentence_dynamics', 'Mix aus kurzen und längeren Sätzen')}
|
||||
Interpunktion: {linguistic.get('punctuation_patterns', 'Standard')}
|
||||
|
||||
Branche: {audience.get('industry_context', 'Business')}
|
||||
|
||||
Zielgruppe: {audience.get('target_audience', 'Professionals')}
|
||||
{phrase_section}
|
||||
{structure_section}
|
||||
|
||||
4. VISUELLE REGELN:
|
||||
|
||||
Unicode-Fettung: Nutze für den ersten Satz (Hook) fette Unicode-Zeichen (z.B. 𝗪𝗶𝗰𝗵𝘁𝗶𝗴𝗲𝗿 𝗦𝗮𝘁𝘇), sofern das zur Person passt: {visual.get('unicode_formatting', 'Fett für Hooks')}
|
||||
|
||||
Emoji-Logik: Verwende diese Emojis: {emoji_str}
|
||||
Platzierung: {visual.get('emoji_usage', {}).get('placement', 'Ende')}
|
||||
Häufigkeit: {visual.get('emoji_usage', {}).get('frequency', 'Mittel')}
|
||||
|
||||
Erzähl-Anker: Baue Elemente ein wie: {narrative_str}
|
||||
(Falls 'PS-Zeilen', 'Dialoge' oder 'Flashbacks' genannt sind, integriere diese wenn es passt.)
|
||||
|
||||
Layout: {visual.get('structure_preferences', 'Kurze Absätze, mobil-optimiert')}
|
||||
|
||||
Länge: Ca. {writing_style.get('average_word_count', 300)} Wörter
|
||||
|
||||
CTA: Beende den Post mit einer Variante von: {content_strategy.get('cta_style', 'Interaktive Frage an die Community')}
|
||||
|
||||
|
||||
5. GUARDRAILS (VERBOTE!):
|
||||
|
||||
Vermeide IMMER diese KI-typischen Muster:
|
||||
- "In der heutigen Zeit", "Tauchen Sie ein", "Es ist kein Geheimnis"
|
||||
- "Stellen Sie sich vor", "Lassen Sie uns", "Es ist wichtig zu verstehen"
|
||||
- Gedankenstriche (–) zur Satzverbindung - nutze stattdessen Kommas oder Punkte
|
||||
- Belehrende Formulierungen wenn die Person eine Ich-Perspektive nutzt
|
||||
- Übertriebene Superlative ohne Substanz
|
||||
- Zu perfekte, glatte Formulierungen - echte Menschen schreiben mit Ecken und Kanten
|
||||
{lessons_section}
|
||||
{post_type_section}
|
||||
DEIN AUFTRAG: Schreibe den Post so, dass er für die Zielgruppe ({audience.get('target_audience', 'Professionals')}) einen klaren Mehrwert bietet und ihre Pain Points ({pain_points_str}) adressiert. Mach die Persönlichkeit des linguistischen Fingerabdrucks spürbar.
|
||||
|
||||
Beginne DIREKT mit dem Hook. Keine einleitenden Sätze, kein "Hier ist der Post"."""
|
||||
|
||||
def _get_user_prompt(
|
||||
self,
|
||||
topic: Dict[str, Any],
|
||||
feedback: Optional[str] = None,
|
||||
previous_version: Optional[str] = None,
|
||||
critic_result: Optional[Dict[str, Any]] = None
|
||||
) -> str:
|
||||
"""Get user prompt for writer."""
|
||||
if feedback and previous_version:
|
||||
# Build specific changes section
|
||||
specific_changes_text = ""
|
||||
if critic_result and critic_result.get("specific_changes"):
|
||||
specific_changes_text = "\n**KONKRETE ÄNDERUNGEN (FÜHRE DIESE EXAKT DURCH!):**\n"
|
||||
for i, change in enumerate(critic_result["specific_changes"], 1):
|
||||
specific_changes_text += f"\n{i}. ERSETZE:\n"
|
||||
specific_changes_text += f" \"{change.get('original', '')}\"\n"
|
||||
specific_changes_text += f" MIT:\n"
|
||||
specific_changes_text += f" \"{change.get('replacement', '')}\"\n"
|
||||
if change.get('reason'):
|
||||
specific_changes_text += f" (Grund: {change.get('reason')})\n"
|
||||
|
||||
# Build improvements section
|
||||
improvements_text = ""
|
||||
if critic_result and critic_result.get("improvements"):
|
||||
improvements_text = "\n**WEITERE VERBESSERUNGEN:**\n"
|
||||
for imp in critic_result["improvements"]:
|
||||
improvements_text += f"- {imp}\n"
|
||||
|
||||
# Revision mode with structured feedback
|
||||
return f"""ÜBERARBEITE den Post basierend auf dem Kritiker-Feedback.
|
||||
|
||||
**VORHERIGE VERSION:**
|
||||
{previous_version}
|
||||
|
||||
**AKTUELLER SCORE:** {critic_result.get('overall_score', 'N/A')}/100
|
||||
|
||||
**FEEDBACK:**
|
||||
{feedback}
|
||||
{specific_changes_text}
|
||||
{improvements_text}
|
||||
**DEINE AUFGABE:**
|
||||
1. Führe die konkreten Änderungen EXAKT durch
|
||||
2. Behalte alles bei was GUT bewertet wurde
|
||||
3. Der überarbeitete Post soll mindestens 85 Punkte erreichen
|
||||
|
||||
Gib NUR den überarbeiteten Post zurück - keine Kommentare."""
|
||||
|
||||
else:
|
||||
# Initial writing mode - enhanced with new topic fields
|
||||
angle_section = ""
|
||||
if topic.get('angle'):
|
||||
angle_section = f"\n**ANGLE/PERSPEKTIVE:**\n{topic.get('angle')}\n"
|
||||
|
||||
hook_section = ""
|
||||
if topic.get('hook_idea'):
|
||||
hook_section = f"\n**HOOK-IDEE (als Inspiration):**\n\"{topic.get('hook_idea')}\"\n"
|
||||
|
||||
facts_section = ""
|
||||
key_facts = topic.get('key_facts', [])
|
||||
if key_facts and isinstance(key_facts, list) and len(key_facts) > 0:
|
||||
facts_section = "\n**KEY FACTS (nutze diese!):**\n" + "\n".join([f"- {f}" for f in key_facts]) + "\n"
|
||||
|
||||
return f"""Schreibe einen LinkedIn-Post zu folgendem Thema:
|
||||
|
||||
**THEMA:** {topic.get('title', 'Unbekanntes Thema')}
|
||||
|
||||
**KATEGORIE:** {topic.get('category', 'Allgemein')}
|
||||
{angle_section}{hook_section}
|
||||
**KERN-FAKT / INHALT:**
|
||||
{topic.get('fact', topic.get('description', ''))}
|
||||
{facts_section}
|
||||
**WARUM RELEVANT:**
|
||||
{topic.get('relevance', 'Aktuelles Thema für die Zielgruppe')}
|
||||
|
||||
**AUFGABE:**
|
||||
Schreibe einen authentischen LinkedIn-Post, der:
|
||||
1. Mit einem STARKEN, unerwarteten Hook beginnt (nutze Hook-Idee als Inspiration!)
|
||||
2. Den Fakt/das Thema aufgreift und Mehrwert bietet
|
||||
3. Die Key Facts einbaut wo es passt
|
||||
4. Eine persönliche Note oder Meinung enthält
|
||||
5. Mit einem passenden CTA endet
|
||||
|
||||
WICHTIG:
|
||||
- Vermeide KI-typische Formulierungen ("In der heutigen Zeit", "Tauchen Sie ein", etc.)
|
||||
- Schreibe natürlich und menschlich
|
||||
- Der Post soll SOFORT 85+ Punkte im Review erreichen
|
||||
|
||||
Gib NUR den fertigen Post zurück."""
|
||||
57
src/config.py
Normal file
57
src/config.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""Configuration management for LinkedIn Workflow."""
|
||||
from typing import Optional
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings loaded from environment variables."""
|
||||
|
||||
# API Keys
|
||||
openai_api_key: str
|
||||
perplexity_api_key: str
|
||||
apify_api_key: str
|
||||
|
||||
# Supabase
|
||||
supabase_url: str
|
||||
supabase_key: str
|
||||
|
||||
# Apify
|
||||
apify_actor_id: str = "apimaestro~linkedin-profile-posts"
|
||||
|
||||
# Web Interface
|
||||
web_password: str = ""
|
||||
session_secret: str = ""
|
||||
|
||||
# Development
|
||||
debug: bool = False
|
||||
log_level: str = "INFO"
|
||||
|
||||
# Email Settings
|
||||
smtp_host: str = ""
|
||||
smtp_port: int = 587
|
||||
smtp_user: str = ""
|
||||
smtp_password: str = ""
|
||||
smtp_from_name: str = "LinkedIn Post System"
|
||||
email_default_recipient: str = ""
|
||||
|
||||
# Writer Features (can be toggled to disable new features)
|
||||
writer_multi_draft_enabled: bool = True # Generate multiple drafts and select best
|
||||
writer_multi_draft_count: int = 3 # Number of drafts to generate (2-5)
|
||||
writer_semantic_matching_enabled: bool = True # Use semantically similar example posts
|
||||
writer_learn_from_feedback: bool = True # Learn from recurring critic feedback
|
||||
writer_feedback_history_count: int = 10 # Number of past posts to analyze for patterns
|
||||
|
||||
# User Frontend (LinkedIn OAuth via Supabase)
|
||||
user_frontend_enabled: bool = True # Enable user frontend with LinkedIn OAuth
|
||||
supabase_redirect_url: str = "" # OAuth Callback URL (e.g., https://linkedin.onyva.dev/auth/callback)
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False
|
||||
)
|
||||
|
||||
|
||||
# Global settings instance
|
||||
settings = Settings()
|
||||
25
src/database/__init__.py
Normal file
25
src/database/__init__.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""Database module."""
|
||||
from src.database.client import DatabaseClient, db
|
||||
from src.database.models import (
|
||||
Customer,
|
||||
LinkedInProfile,
|
||||
LinkedInPost,
|
||||
Topic,
|
||||
ProfileAnalysis,
|
||||
ResearchResult,
|
||||
GeneratedPost,
|
||||
PostType,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DatabaseClient",
|
||||
"db",
|
||||
"Customer",
|
||||
"LinkedInProfile",
|
||||
"LinkedInPost",
|
||||
"Topic",
|
||||
"ProfileAnalysis",
|
||||
"ResearchResult",
|
||||
"GeneratedPost",
|
||||
"PostType",
|
||||
]
|
||||
533
src/database/client.py
Normal file
533
src/database/client.py
Normal file
@@ -0,0 +1,533 @@
|
||||
"""Supabase database client."""
|
||||
import asyncio
|
||||
from typing import Optional, List, Dict, Any
|
||||
from uuid import UUID
|
||||
from supabase import create_client, Client
|
||||
from loguru import logger
|
||||
|
||||
from src.config import settings
|
||||
from src.database.models import (
|
||||
Customer, LinkedInProfile, LinkedInPost, Topic,
|
||||
ProfileAnalysis, ResearchResult, GeneratedPost, PostType
|
||||
)
|
||||
|
||||
|
||||
class DatabaseClient:
|
||||
"""Supabase database client wrapper."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize Supabase client."""
|
||||
self.client: Client = create_client(
|
||||
settings.supabase_url,
|
||||
settings.supabase_key
|
||||
)
|
||||
logger.info("Supabase client initialized")
|
||||
|
||||
# ==================== CUSTOMERS ====================
|
||||
|
||||
async def create_customer(self, customer: Customer) -> Customer:
|
||||
"""Create a new customer."""
|
||||
data = customer.model_dump(exclude={"id", "created_at", "updated_at"}, exclude_none=True)
|
||||
result = await asyncio.to_thread(
|
||||
lambda: self.client.table("customers").insert(data).execute()
|
||||
)
|
||||
logger.info(f"Created customer: {result.data[0]['id']}")
|
||||
return Customer(**result.data[0])
|
||||
|
||||
async def get_customer(self, customer_id: UUID) -> Optional[Customer]:
|
||||
"""Get customer by ID."""
|
||||
result = await asyncio.to_thread(
|
||||
lambda: self.client.table("customers").select("*").eq("id", str(customer_id)).execute()
|
||||
)
|
||||
if result.data:
|
||||
return Customer(**result.data[0])
|
||||
return None
|
||||
|
||||
async def get_customer_by_linkedin(self, linkedin_url: str) -> Optional[Customer]:
|
||||
"""Get customer by LinkedIn URL."""
|
||||
result = await asyncio.to_thread(
|
||||
lambda: self.client.table("customers").select("*").eq("linkedin_url", linkedin_url).execute()
|
||||
)
|
||||
if result.data:
|
||||
return Customer(**result.data[0])
|
||||
return None
|
||||
|
||||
async def list_customers(self) -> List[Customer]:
|
||||
"""List all customers."""
|
||||
result = await asyncio.to_thread(
|
||||
lambda: self.client.table("customers").select("*").execute()
|
||||
)
|
||||
return [Customer(**item) for item in result.data]
|
||||
|
||||
# ==================== LINKEDIN PROFILES ====================
|
||||
|
||||
async def save_linkedin_profile(self, profile: LinkedInProfile) -> LinkedInProfile:
|
||||
"""Save or update LinkedIn profile."""
|
||||
data = profile.model_dump(exclude={"id", "scraped_at"}, exclude_none=True)
|
||||
# Convert UUID to string for Supabase
|
||||
if "customer_id" in data:
|
||||
data["customer_id"] = str(data["customer_id"])
|
||||
|
||||
# Check if profile exists
|
||||
existing = await asyncio.to_thread(
|
||||
lambda: self.client.table("linkedin_profiles").select("*").eq(
|
||||
"customer_id", str(profile.customer_id)
|
||||
).execute()
|
||||
)
|
||||
|
||||
if existing.data:
|
||||
# Update existing
|
||||
result = await asyncio.to_thread(
|
||||
lambda: self.client.table("linkedin_profiles").update(data).eq(
|
||||
"customer_id", str(profile.customer_id)
|
||||
).execute()
|
||||
)
|
||||
else:
|
||||
# Insert new
|
||||
result = await asyncio.to_thread(
|
||||
lambda: self.client.table("linkedin_profiles").insert(data).execute()
|
||||
)
|
||||
|
||||
logger.info(f"Saved LinkedIn profile for customer: {profile.customer_id}")
|
||||
return LinkedInProfile(**result.data[0])
|
||||
|
||||
async def get_linkedin_profile(self, customer_id: UUID) -> Optional[LinkedInProfile]:
|
||||
"""Get LinkedIn profile for customer."""
|
||||
result = await asyncio.to_thread(
|
||||
lambda: self.client.table("linkedin_profiles").select("*").eq(
|
||||
"customer_id", str(customer_id)
|
||||
).execute()
|
||||
)
|
||||
if result.data:
|
||||
return LinkedInProfile(**result.data[0])
|
||||
return None
|
||||
|
||||
# ==================== LINKEDIN POSTS ====================
|
||||
|
||||
async def save_linkedin_posts(self, posts: List[LinkedInPost]) -> List[LinkedInPost]:
|
||||
"""Save LinkedIn posts (bulk)."""
|
||||
from datetime import datetime
|
||||
|
||||
# Deduplicate posts based on (customer_id, post_url) before saving
|
||||
seen = set()
|
||||
unique_posts = []
|
||||
for p in posts:
|
||||
key = (str(p.customer_id), p.post_url)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
unique_posts.append(p)
|
||||
|
||||
if len(posts) != len(unique_posts):
|
||||
logger.warning(f"Removed {len(posts) - len(unique_posts)} duplicate posts from batch")
|
||||
|
||||
data = []
|
||||
for p in unique_posts:
|
||||
post_dict = p.model_dump(exclude={"id", "scraped_at"}, exclude_none=True)
|
||||
# Convert UUID to string for Supabase
|
||||
if "customer_id" in post_dict:
|
||||
post_dict["customer_id"] = str(post_dict["customer_id"])
|
||||
|
||||
# Convert datetime to ISO string for Supabase
|
||||
if "post_date" in post_dict and isinstance(post_dict["post_date"], datetime):
|
||||
post_dict["post_date"] = post_dict["post_date"].isoformat()
|
||||
|
||||
data.append(post_dict)
|
||||
|
||||
if not data:
|
||||
logger.warning("No posts to save")
|
||||
return []
|
||||
|
||||
# Use upsert with on_conflict to handle duplicates based on (customer_id, post_url)
|
||||
# This will update existing posts instead of throwing an error
|
||||
result = await asyncio.to_thread(
|
||||
lambda: self.client.table("linkedin_posts").upsert(
|
||||
data,
|
||||
on_conflict="customer_id,post_url"
|
||||
).execute()
|
||||
)
|
||||
logger.info(f"Saved {len(result.data)} LinkedIn posts")
|
||||
return [LinkedInPost(**item) for item in result.data]
|
||||
|
||||
async def get_linkedin_posts(self, customer_id: UUID) -> List[LinkedInPost]:
|
||||
"""Get all LinkedIn posts for customer."""
|
||||
result = await asyncio.to_thread(
|
||||
lambda: self.client.table("linkedin_posts").select("*").eq(
|
||||
"customer_id", str(customer_id)
|
||||
).order("post_date", desc=True).execute()
|
||||
)
|
||||
return [LinkedInPost(**item) for item in result.data]
|
||||
|
||||
async def get_unclassified_posts(self, customer_id: UUID) -> List[LinkedInPost]:
|
||||
"""Get all LinkedIn posts without a post_type_id."""
|
||||
result = await asyncio.to_thread(
|
||||
lambda: self.client.table("linkedin_posts").select("*").eq(
|
||||
"customer_id", str(customer_id)
|
||||
).is_("post_type_id", "null").execute()
|
||||
)
|
||||
return [LinkedInPost(**item) for item in result.data]
|
||||
|
||||
async def get_posts_by_type(self, customer_id: UUID, post_type_id: UUID) -> List[LinkedInPost]:
|
||||
"""Get all LinkedIn posts for a specific post type."""
|
||||
result = await asyncio.to_thread(
|
||||
lambda: self.client.table("linkedin_posts").select("*").eq(
|
||||
"customer_id", str(customer_id)
|
||||
).eq("post_type_id", str(post_type_id)).order("post_date", desc=True).execute()
|
||||
)
|
||||
return [LinkedInPost(**item) for item in result.data]
|
||||
|
||||
async def update_post_classification(
|
||||
self,
|
||||
post_id: UUID,
|
||||
post_type_id: UUID,
|
||||
classification_method: str,
|
||||
classification_confidence: float
|
||||
) -> None:
|
||||
"""Update a single post's classification."""
|
||||
await asyncio.to_thread(
|
||||
lambda: self.client.table("linkedin_posts").update({
|
||||
"post_type_id": str(post_type_id),
|
||||
"classification_method": classification_method,
|
||||
"classification_confidence": classification_confidence
|
||||
}).eq("id", str(post_id)).execute()
|
||||
)
|
||||
logger.debug(f"Updated classification for post {post_id}")
|
||||
|
||||
async def update_posts_classification_bulk(
|
||||
self,
|
||||
classifications: List[Dict[str, Any]]
|
||||
) -> int:
|
||||
"""
|
||||
Bulk update post classifications.
|
||||
|
||||
Args:
|
||||
classifications: List of dicts with post_id, post_type_id, classification_method, classification_confidence
|
||||
|
||||
Returns:
|
||||
Number of posts updated
|
||||
"""
|
||||
count = 0
|
||||
for classification in classifications:
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
lambda c=classification: self.client.table("linkedin_posts").update({
|
||||
"post_type_id": str(c["post_type_id"]),
|
||||
"classification_method": c["classification_method"],
|
||||
"classification_confidence": c["classification_confidence"]
|
||||
}).eq("id", str(c["post_id"])).execute()
|
||||
)
|
||||
count += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to update classification for post {classification['post_id']}: {e}")
|
||||
logger.info(f"Bulk updated classifications for {count} posts")
|
||||
return count
|
||||
|
||||
# ==================== POST TYPES ====================
|
||||
|
||||
async def create_post_type(self, post_type: PostType) -> PostType:
|
||||
"""Create a new post type."""
|
||||
data = post_type.model_dump(exclude={"id", "created_at", "updated_at"}, exclude_none=True)
|
||||
# Convert UUID to string
|
||||
if "customer_id" in data:
|
||||
data["customer_id"] = str(data["customer_id"])
|
||||
|
||||
result = await asyncio.to_thread(
|
||||
lambda: self.client.table("post_types").insert(data).execute()
|
||||
)
|
||||
logger.info(f"Created post type: {result.data[0]['name']}")
|
||||
return PostType(**result.data[0])
|
||||
|
||||
async def create_post_types_bulk(self, post_types: List[PostType]) -> List[PostType]:
|
||||
"""Create multiple post types at once."""
|
||||
if not post_types:
|
||||
return []
|
||||
|
||||
data = []
|
||||
for pt in post_types:
|
||||
pt_dict = pt.model_dump(exclude={"id", "created_at", "updated_at"}, exclude_none=True)
|
||||
if "customer_id" in pt_dict:
|
||||
pt_dict["customer_id"] = str(pt_dict["customer_id"])
|
||||
data.append(pt_dict)
|
||||
|
||||
result = await asyncio.to_thread(
|
||||
lambda: self.client.table("post_types").insert(data).execute()
|
||||
)
|
||||
logger.info(f"Created {len(result.data)} post types")
|
||||
return [PostType(**item) for item in result.data]
|
||||
|
||||
async def get_post_types(self, customer_id: UUID, active_only: bool = True) -> List[PostType]:
|
||||
"""Get all post types for a customer."""
|
||||
def _query():
|
||||
query = self.client.table("post_types").select("*").eq("customer_id", str(customer_id))
|
||||
if active_only:
|
||||
query = query.eq("is_active", True)
|
||||
return query.order("name").execute()
|
||||
|
||||
result = await asyncio.to_thread(_query)
|
||||
return [PostType(**item) for item in result.data]
|
||||
|
||||
async def get_post_type(self, post_type_id: UUID) -> Optional[PostType]:
|
||||
"""Get a single post type by ID."""
|
||||
result = await asyncio.to_thread(
|
||||
lambda: self.client.table("post_types").select("*").eq(
|
||||
"id", str(post_type_id)
|
||||
).execute()
|
||||
)
|
||||
if result.data:
|
||||
return PostType(**result.data[0])
|
||||
return None
|
||||
|
||||
async def update_post_type(self, post_type_id: UUID, updates: Dict[str, Any]) -> PostType:
|
||||
"""Update a post type."""
|
||||
result = await asyncio.to_thread(
|
||||
lambda: self.client.table("post_types").update(updates).eq(
|
||||
"id", str(post_type_id)
|
||||
).execute()
|
||||
)
|
||||
logger.info(f"Updated post type: {post_type_id}")
|
||||
return PostType(**result.data[0])
|
||||
|
||||
async def update_post_type_analysis(
|
||||
self,
|
||||
post_type_id: UUID,
|
||||
analysis: Dict[str, Any],
|
||||
analyzed_post_count: int
|
||||
) -> PostType:
|
||||
"""Update the analysis for a post type."""
|
||||
from datetime import datetime
|
||||
result = await asyncio.to_thread(
|
||||
lambda: self.client.table("post_types").update({
|
||||
"analysis": analysis,
|
||||
"analysis_generated_at": datetime.now().isoformat(),
|
||||
"analyzed_post_count": analyzed_post_count
|
||||
}).eq("id", str(post_type_id)).execute()
|
||||
)
|
||||
logger.info(f"Updated analysis for post type: {post_type_id}")
|
||||
return PostType(**result.data[0])
|
||||
|
||||
async def delete_post_type(self, post_type_id: UUID, soft: bool = True) -> None:
|
||||
"""Delete a post type (soft delete by default)."""
|
||||
if soft:
|
||||
await asyncio.to_thread(
|
||||
lambda: self.client.table("post_types").update({
|
||||
"is_active": False
|
||||
}).eq("id", str(post_type_id)).execute()
|
||||
)
|
||||
logger.info(f"Soft deleted post type: {post_type_id}")
|
||||
else:
|
||||
await asyncio.to_thread(
|
||||
lambda: self.client.table("post_types").delete().eq(
|
||||
"id", str(post_type_id)
|
||||
).execute()
|
||||
)
|
||||
logger.info(f"Hard deleted post type: {post_type_id}")
|
||||
|
||||
# ==================== TOPICS ====================
|
||||
|
||||
async def save_topics(self, topics: List[Topic]) -> List[Topic]:
|
||||
"""Save extracted topics."""
|
||||
if not topics:
|
||||
logger.warning("No topics to save")
|
||||
return []
|
||||
|
||||
data = []
|
||||
for t in topics:
|
||||
topic_dict = t.model_dump(exclude={"id", "created_at"}, exclude_none=True)
|
||||
# Convert UUID to string for Supabase
|
||||
if "customer_id" in topic_dict:
|
||||
topic_dict["customer_id"] = str(topic_dict["customer_id"])
|
||||
if "extracted_from_post_id" in topic_dict and topic_dict["extracted_from_post_id"]:
|
||||
topic_dict["extracted_from_post_id"] = str(topic_dict["extracted_from_post_id"])
|
||||
if "target_post_type_id" in topic_dict and topic_dict["target_post_type_id"]:
|
||||
topic_dict["target_post_type_id"] = str(topic_dict["target_post_type_id"])
|
||||
data.append(topic_dict)
|
||||
|
||||
try:
|
||||
# Use insert and handle duplicates manually
|
||||
result = await asyncio.to_thread(
|
||||
lambda: self.client.table("topics").insert(data).execute()
|
||||
)
|
||||
logger.info(f"Saved {len(result.data)} topics to database")
|
||||
return [Topic(**item) for item in result.data]
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving topics: {e}", exc_info=True)
|
||||
# Try one by one if batch fails
|
||||
saved = []
|
||||
for topic_data in data:
|
||||
try:
|
||||
result = await asyncio.to_thread(
|
||||
lambda td=topic_data: self.client.table("topics").insert(td).execute()
|
||||
)
|
||||
saved.extend([Topic(**item) for item in result.data])
|
||||
except Exception as single_error:
|
||||
logger.warning(f"Skipped duplicate topic: {topic_data.get('title')}")
|
||||
logger.info(f"Saved {len(saved)} topics individually")
|
||||
return saved
|
||||
|
||||
async def get_topics(
|
||||
self,
|
||||
customer_id: UUID,
|
||||
unused_only: bool = False,
|
||||
post_type_id: Optional[UUID] = None
|
||||
) -> List[Topic]:
|
||||
"""Get topics for customer, optionally filtered by post type."""
|
||||
def _query():
|
||||
query = self.client.table("topics").select("*").eq("customer_id", str(customer_id))
|
||||
if unused_only:
|
||||
query = query.eq("is_used", False)
|
||||
if post_type_id:
|
||||
query = query.eq("target_post_type_id", str(post_type_id))
|
||||
return query.order("created_at", desc=True).execute()
|
||||
|
||||
result = await asyncio.to_thread(_query)
|
||||
return [Topic(**item) for item in result.data]
|
||||
|
||||
async def mark_topic_used(self, topic_id: UUID) -> None:
|
||||
"""Mark topic as used."""
|
||||
await asyncio.to_thread(
|
||||
lambda: self.client.table("topics").update({
|
||||
"is_used": True,
|
||||
"used_at": "now()"
|
||||
}).eq("id", str(topic_id)).execute()
|
||||
)
|
||||
logger.info(f"Marked topic {topic_id} as used")
|
||||
|
||||
# ==================== PROFILE ANALYSIS ====================
|
||||
|
||||
async def save_profile_analysis(self, analysis: ProfileAnalysis) -> ProfileAnalysis:
|
||||
"""Save profile analysis."""
|
||||
data = analysis.model_dump(exclude={"id", "created_at"}, exclude_none=True)
|
||||
# Convert UUID to string for Supabase
|
||||
if "customer_id" in data:
|
||||
data["customer_id"] = str(data["customer_id"])
|
||||
|
||||
# Check if analysis exists
|
||||
existing = await asyncio.to_thread(
|
||||
lambda: self.client.table("profile_analyses").select("*").eq(
|
||||
"customer_id", str(analysis.customer_id)
|
||||
).execute()
|
||||
)
|
||||
|
||||
if existing.data:
|
||||
# Update existing
|
||||
result = await asyncio.to_thread(
|
||||
lambda: self.client.table("profile_analyses").update(data).eq(
|
||||
"customer_id", str(analysis.customer_id)
|
||||
).execute()
|
||||
)
|
||||
else:
|
||||
# Insert new
|
||||
result = await asyncio.to_thread(
|
||||
lambda: self.client.table("profile_analyses").insert(data).execute()
|
||||
)
|
||||
|
||||
logger.info(f"Saved profile analysis for customer: {analysis.customer_id}")
|
||||
return ProfileAnalysis(**result.data[0])
|
||||
|
||||
async def get_profile_analysis(self, customer_id: UUID) -> Optional[ProfileAnalysis]:
|
||||
"""Get profile analysis for customer."""
|
||||
result = await asyncio.to_thread(
|
||||
lambda: self.client.table("profile_analyses").select("*").eq(
|
||||
"customer_id", str(customer_id)
|
||||
).execute()
|
||||
)
|
||||
if result.data:
|
||||
return ProfileAnalysis(**result.data[0])
|
||||
return None
|
||||
|
||||
# ==================== RESEARCH RESULTS ====================
|
||||
|
||||
async def save_research_result(self, research: ResearchResult) -> ResearchResult:
|
||||
"""Save research result."""
|
||||
data = research.model_dump(exclude={"id", "created_at"}, exclude_none=True)
|
||||
# Convert UUIDs to string for Supabase
|
||||
if "customer_id" in data:
|
||||
data["customer_id"] = str(data["customer_id"])
|
||||
if "target_post_type_id" in data and data["target_post_type_id"]:
|
||||
data["target_post_type_id"] = str(data["target_post_type_id"])
|
||||
|
||||
result = await asyncio.to_thread(
|
||||
lambda: self.client.table("research_results").insert(data).execute()
|
||||
)
|
||||
logger.info(f"Saved research result for customer: {research.customer_id}")
|
||||
return ResearchResult(**result.data[0])
|
||||
|
||||
async def get_latest_research(self, customer_id: UUID) -> Optional[ResearchResult]:
|
||||
"""Get latest research result for customer."""
|
||||
result = await asyncio.to_thread(
|
||||
lambda: self.client.table("research_results").select("*").eq(
|
||||
"customer_id", str(customer_id)
|
||||
).order("created_at", desc=True).limit(1).execute()
|
||||
)
|
||||
if result.data:
|
||||
return ResearchResult(**result.data[0])
|
||||
return None
|
||||
|
||||
async def get_all_research(
|
||||
self,
|
||||
customer_id: UUID,
|
||||
post_type_id: Optional[UUID] = None
|
||||
) -> List[ResearchResult]:
|
||||
"""Get all research results for customer, optionally filtered by post type."""
|
||||
def _query():
|
||||
query = self.client.table("research_results").select("*").eq(
|
||||
"customer_id", str(customer_id)
|
||||
)
|
||||
if post_type_id:
|
||||
query = query.eq("target_post_type_id", str(post_type_id))
|
||||
return query.order("created_at", desc=True).execute()
|
||||
|
||||
result = await asyncio.to_thread(_query)
|
||||
return [ResearchResult(**item) for item in result.data]
|
||||
|
||||
# ==================== GENERATED POSTS ====================
|
||||
|
||||
async def save_generated_post(self, post: GeneratedPost) -> GeneratedPost:
|
||||
"""Save generated post."""
|
||||
data = post.model_dump(exclude={"id", "created_at"}, exclude_none=True)
|
||||
# Convert UUIDs to string for Supabase
|
||||
if "customer_id" in data:
|
||||
data["customer_id"] = str(data["customer_id"])
|
||||
if "topic_id" in data and data["topic_id"]:
|
||||
data["topic_id"] = str(data["topic_id"])
|
||||
if "post_type_id" in data and data["post_type_id"]:
|
||||
data["post_type_id"] = str(data["post_type_id"])
|
||||
|
||||
result = await asyncio.to_thread(
|
||||
lambda: self.client.table("generated_posts").insert(data).execute()
|
||||
)
|
||||
logger.info(f"Saved generated post: {result.data[0]['id']}")
|
||||
return GeneratedPost(**result.data[0])
|
||||
|
||||
async def update_generated_post(self, post_id: UUID, updates: Dict[str, Any]) -> GeneratedPost:
|
||||
"""Update generated post."""
|
||||
result = await asyncio.to_thread(
|
||||
lambda: self.client.table("generated_posts").update(updates).eq(
|
||||
"id", str(post_id)
|
||||
).execute()
|
||||
)
|
||||
logger.info(f"Updated generated post: {post_id}")
|
||||
return GeneratedPost(**result.data[0])
|
||||
|
||||
async def get_generated_posts(self, customer_id: UUID) -> List[GeneratedPost]:
|
||||
"""Get all generated posts for customer."""
|
||||
result = await asyncio.to_thread(
|
||||
lambda: self.client.table("generated_posts").select("*").eq(
|
||||
"customer_id", str(customer_id)
|
||||
).order("created_at", desc=True).execute()
|
||||
)
|
||||
return [GeneratedPost(**item) for item in result.data]
|
||||
|
||||
async def get_generated_post(self, post_id: UUID) -> Optional[GeneratedPost]:
|
||||
"""Get a single generated post by ID."""
|
||||
result = await asyncio.to_thread(
|
||||
lambda: self.client.table("generated_posts").select("*").eq(
|
||||
"id", str(post_id)
|
||||
).execute()
|
||||
)
|
||||
if result.data:
|
||||
return GeneratedPost(**result.data[0])
|
||||
return None
|
||||
|
||||
|
||||
# Global database client instance
|
||||
db = DatabaseClient()
|
||||
126
src/database/models.py
Normal file
126
src/database/models.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""Pydantic models for database entities."""
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any, List
|
||||
from uuid import UUID
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
|
||||
|
||||
class DBModel(BaseModel):
|
||||
"""Base model for database entities with extra fields ignored."""
|
||||
model_config = ConfigDict(extra='ignore')
|
||||
|
||||
|
||||
class Customer(DBModel):
|
||||
"""Customer/Client model."""
|
||||
id: Optional[UUID] = None
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
name: str
|
||||
email: Optional[str] = None
|
||||
company_name: Optional[str] = None
|
||||
linkedin_url: str
|
||||
metadata: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class PostType(DBModel):
|
||||
"""Post type model for categorizing different types of posts."""
|
||||
id: Optional[UUID] = None
|
||||
customer_id: UUID
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
identifying_hashtags: List[str] = Field(default_factory=list)
|
||||
identifying_keywords: List[str] = Field(default_factory=list)
|
||||
semantic_properties: Dict[str, Any] = Field(default_factory=dict)
|
||||
analysis: Optional[Dict[str, Any]] = None
|
||||
analysis_generated_at: Optional[datetime] = None
|
||||
analyzed_post_count: int = 0
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class LinkedInProfile(DBModel):
|
||||
"""LinkedIn profile model."""
|
||||
id: Optional[UUID] = None
|
||||
customer_id: UUID
|
||||
scraped_at: Optional[datetime] = None
|
||||
profile_data: Dict[str, Any]
|
||||
name: Optional[str] = None
|
||||
headline: Optional[str] = None
|
||||
summary: Optional[str] = None
|
||||
location: Optional[str] = None
|
||||
industry: Optional[str] = None
|
||||
|
||||
|
||||
class LinkedInPost(DBModel):
|
||||
"""LinkedIn post model."""
|
||||
id: Optional[UUID] = None
|
||||
customer_id: UUID
|
||||
scraped_at: Optional[datetime] = None
|
||||
post_url: Optional[str] = None
|
||||
post_text: str
|
||||
post_date: Optional[datetime] = None
|
||||
likes: int = 0
|
||||
comments: int = 0
|
||||
shares: int = 0
|
||||
raw_data: Optional[Dict[str, Any]] = None
|
||||
# Post type classification fields
|
||||
post_type_id: Optional[UUID] = None
|
||||
classification_method: Optional[str] = None # 'hashtag', 'keyword', 'semantic'
|
||||
classification_confidence: Optional[float] = None
|
||||
|
||||
|
||||
class Topic(DBModel):
|
||||
"""Topic model."""
|
||||
id: Optional[UUID] = None
|
||||
customer_id: UUID
|
||||
created_at: Optional[datetime] = None
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
extracted_from_post_id: Optional[UUID] = None
|
||||
extraction_confidence: Optional[float] = None
|
||||
is_used: bool = False
|
||||
used_at: Optional[datetime] = None
|
||||
target_post_type_id: Optional[UUID] = None # Target post type for this topic
|
||||
|
||||
|
||||
class ProfileAnalysis(DBModel):
|
||||
"""Profile analysis model."""
|
||||
id: Optional[UUID] = None
|
||||
customer_id: UUID
|
||||
created_at: Optional[datetime] = None
|
||||
writing_style: Dict[str, Any]
|
||||
tone_analysis: Dict[str, Any]
|
||||
topic_patterns: Dict[str, Any]
|
||||
audience_insights: Dict[str, Any]
|
||||
full_analysis: Dict[str, Any]
|
||||
|
||||
|
||||
class ResearchResult(DBModel):
|
||||
"""Research result model."""
|
||||
id: Optional[UUID] = None
|
||||
customer_id: UUID
|
||||
created_at: Optional[datetime] = None
|
||||
query: str
|
||||
results: Dict[str, Any]
|
||||
suggested_topics: List[Dict[str, Any]]
|
||||
source: str = "perplexity"
|
||||
target_post_type_id: Optional[UUID] = None # Target post type for this research
|
||||
|
||||
|
||||
class GeneratedPost(DBModel):
|
||||
"""Generated post model."""
|
||||
id: Optional[UUID] = None
|
||||
customer_id: UUID
|
||||
created_at: Optional[datetime] = None
|
||||
topic_id: Optional[UUID] = None
|
||||
topic_title: str
|
||||
post_content: str
|
||||
iterations: int = 0
|
||||
writer_versions: List[str] = Field(default_factory=list)
|
||||
critic_feedback: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
status: str = "draft" # draft, approved, published, rejected
|
||||
approved_at: Optional[datetime] = None
|
||||
published_at: Optional[datetime] = None
|
||||
post_type_id: Optional[UUID] = None # Post type used for this generated post
|
||||
144
src/email_service.py
Normal file
144
src/email_service.py
Normal file
@@ -0,0 +1,144 @@
|
||||
"""Email service for sending posts via email."""
|
||||
import base64
|
||||
import html
|
||||
import smtplib
|
||||
import ssl
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from loguru import logger
|
||||
|
||||
from src.config import settings
|
||||
|
||||
|
||||
def _load_logo_base64() -> str:
|
||||
"""Load and encode the logo as base64."""
|
||||
logo_path = Path(__file__).parent / "web" / "static" / "logo.png"
|
||||
if logo_path.exists():
|
||||
with open(logo_path, "rb") as f:
|
||||
return base64.b64encode(f.read()).decode("utf-8")
|
||||
return ""
|
||||
|
||||
|
||||
# Pre-load logo at module import
|
||||
_LOGO_BASE64 = _load_logo_base64()
|
||||
|
||||
|
||||
class EmailService:
|
||||
"""Service for sending emails."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize email service."""
|
||||
self.host = settings.smtp_host
|
||||
self.port = settings.smtp_port
|
||||
self.user = settings.smtp_user
|
||||
self.password = settings.smtp_password
|
||||
self.from_name = settings.smtp_from_name
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
"""Check if email is properly configured."""
|
||||
return bool(self.host and self.user and self.password)
|
||||
|
||||
def send_post(
|
||||
self,
|
||||
recipient: str,
|
||||
post_content: str,
|
||||
topic_title: str,
|
||||
customer_name: str,
|
||||
score: Optional[int] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Send a post via email.
|
||||
|
||||
Args:
|
||||
recipient: Email address to send to
|
||||
post_content: The post content
|
||||
topic_title: Title of the topic
|
||||
customer_name: Name of the customer
|
||||
score: Optional critic score
|
||||
|
||||
Returns:
|
||||
True if sent successfully, False otherwise
|
||||
"""
|
||||
if not self.is_configured():
|
||||
logger.error("Email not configured. Set SMTP_HOST, SMTP_USER, and SMTP_PASSWORD.")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Create message
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["Subject"] = f"Dein LinkedIn Post: {topic_title}"
|
||||
msg["From"] = f"onyva <{self.user}>"
|
||||
msg["To"] = recipient
|
||||
|
||||
# Plain text version - just the post
|
||||
text_content = f"""{post_content}
|
||||
|
||||
--
|
||||
onyva"""
|
||||
|
||||
# HTML version - minimal, just post + onyva logo
|
||||
logo_html = ""
|
||||
if _LOGO_BASE64:
|
||||
logo_html = f'<img src="data:image/png;base64,{_LOGO_BASE64}" alt="onyva" style="height: 32px; width: auto;">'
|
||||
else:
|
||||
# Fallback if logo not found
|
||||
logo_html = '<span style="font-size: 14px; color: #666; font-weight: 500;">onyva</span>'
|
||||
|
||||
# Convert newlines to <br> for email client compatibility
|
||||
post_html = html.escape(post_content).replace('\n', '<br>\n')
|
||||
|
||||
html_content = f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background-color: #ffffff; margin: 0; padding: 40px 20px; color: #1a1a1a; }}
|
||||
.container {{ max-width: 560px; margin: 0 auto; }}
|
||||
.post {{ font-size: 15px; line-height: 1.7; color: #1a1a1a; margin-bottom: 40px; }}
|
||||
.footer {{ padding-top: 24px; border-top: 1px solid #e5e5e5; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="post">{post_html}</div>
|
||||
<div class="footer">
|
||||
{logo_html}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
# Attach both versions
|
||||
msg.attach(MIMEText(text_content, "plain", "utf-8"))
|
||||
msg.attach(MIMEText(html_content, "html", "utf-8"))
|
||||
|
||||
# Send email
|
||||
context = ssl.create_default_context()
|
||||
|
||||
with smtplib.SMTP(self.host, self.port) as server:
|
||||
server.ehlo()
|
||||
server.starttls(context=context)
|
||||
server.ehlo()
|
||||
server.login(self.user, self.password)
|
||||
server.sendmail(self.user, recipient, msg.as_string())
|
||||
|
||||
logger.info(f"Email sent successfully to {recipient}")
|
||||
return True
|
||||
|
||||
except smtplib.SMTPAuthenticationError as e:
|
||||
logger.error(f"SMTP Authentication failed: {e}")
|
||||
return False
|
||||
except smtplib.SMTPException as e:
|
||||
logger.error(f"SMTP error: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send email: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# Global email service instance
|
||||
email_service = EmailService()
|
||||
743
src/orchestrator.py
Normal file
743
src/orchestrator.py
Normal file
@@ -0,0 +1,743 @@
|
||||
"""Main orchestrator for the LinkedIn workflow."""
|
||||
from collections import Counter
|
||||
from typing import Dict, Any, List, Optional, Callable
|
||||
from uuid import UUID
|
||||
from loguru import logger
|
||||
|
||||
from src.config import settings
|
||||
from src.database import db, Customer, LinkedInProfile, LinkedInPost, Topic
|
||||
from src.scraper import scraper
|
||||
from src.agents import (
|
||||
ProfileAnalyzerAgent,
|
||||
TopicExtractorAgent,
|
||||
ResearchAgent,
|
||||
WriterAgent,
|
||||
CriticAgent,
|
||||
PostClassifierAgent,
|
||||
PostTypeAnalyzerAgent,
|
||||
)
|
||||
from src.database.models import PostType
|
||||
|
||||
|
||||
class WorkflowOrchestrator:
|
||||
"""Orchestrates the entire LinkedIn post creation workflow."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize orchestrator with all agents."""
|
||||
self.profile_analyzer = ProfileAnalyzerAgent()
|
||||
self.topic_extractor = TopicExtractorAgent()
|
||||
self.researcher = ResearchAgent()
|
||||
self.writer = WriterAgent()
|
||||
self.critic = CriticAgent()
|
||||
self.post_classifier = PostClassifierAgent()
|
||||
self.post_type_analyzer = PostTypeAnalyzerAgent()
|
||||
logger.info("WorkflowOrchestrator initialized")
|
||||
|
||||
async def run_initial_setup(
|
||||
self,
|
||||
linkedin_url: str,
|
||||
customer_name: str,
|
||||
customer_data: Dict[str, Any],
|
||||
post_types_data: Optional[List[Dict[str, Any]]] = None
|
||||
) -> Customer:
|
||||
"""
|
||||
Run initial setup for a new customer.
|
||||
|
||||
This includes:
|
||||
1. Creating customer record
|
||||
2. Creating post types (if provided)
|
||||
3. Scraping LinkedIn posts (NO profile scraping)
|
||||
4. Creating profile from customer_data
|
||||
5. Analyzing profile
|
||||
6. Extracting topics from existing posts
|
||||
7. Classifying posts by type (if post types exist)
|
||||
8. Analyzing post types (if enough posts)
|
||||
|
||||
Args:
|
||||
linkedin_url: LinkedIn profile URL
|
||||
customer_name: Customer name
|
||||
customer_data: Complete customer data (company, persona, style_guide, etc.)
|
||||
post_types_data: Optional list of post type definitions
|
||||
|
||||
Returns:
|
||||
Customer object
|
||||
"""
|
||||
logger.info(f"=== INITIAL SETUP for {customer_name} ===")
|
||||
|
||||
# Step 1: Check if customer already exists
|
||||
existing_customer = await db.get_customer_by_linkedin(linkedin_url)
|
||||
if existing_customer:
|
||||
logger.warning(f"Customer already exists: {existing_customer.id}")
|
||||
return existing_customer
|
||||
|
||||
# Step 2: Create customer
|
||||
total_steps = 7 if post_types_data else 5
|
||||
logger.info(f"Step 1/{total_steps}: Creating customer record")
|
||||
customer = Customer(
|
||||
name=customer_name,
|
||||
linkedin_url=linkedin_url,
|
||||
company_name=customer_data.get("company_name"),
|
||||
email=customer_data.get("email"),
|
||||
metadata=customer_data
|
||||
)
|
||||
customer = await db.create_customer(customer)
|
||||
logger.info(f"Customer created: {customer.id}")
|
||||
|
||||
# Step 2.5: Create post types if provided
|
||||
created_post_types = []
|
||||
if post_types_data:
|
||||
logger.info(f"Step 2/{total_steps}: Creating {len(post_types_data)} post types")
|
||||
for pt_data in post_types_data:
|
||||
post_type = PostType(
|
||||
customer_id=customer.id,
|
||||
name=pt_data.get("name", "Unnamed"),
|
||||
description=pt_data.get("description"),
|
||||
identifying_hashtags=pt_data.get("identifying_hashtags", []),
|
||||
identifying_keywords=pt_data.get("identifying_keywords", []),
|
||||
semantic_properties=pt_data.get("semantic_properties", {})
|
||||
)
|
||||
created_post_types.append(post_type)
|
||||
|
||||
if created_post_types:
|
||||
created_post_types = await db.create_post_types_bulk(created_post_types)
|
||||
logger.info(f"Created {len(created_post_types)} post types")
|
||||
|
||||
# Step 3: Create LinkedIn profile from customer data (NO scraping)
|
||||
step_num = 3 if post_types_data else 2
|
||||
logger.info(f"Step {step_num}/{total_steps}: Creating LinkedIn profile from provided data")
|
||||
linkedin_profile = LinkedInProfile(
|
||||
customer_id=customer.id,
|
||||
profile_data={
|
||||
"persona": customer_data.get("persona"),
|
||||
"form_of_address": customer_data.get("form_of_address"),
|
||||
"style_guide": customer_data.get("style_guide"),
|
||||
"linkedin_url": linkedin_url
|
||||
},
|
||||
name=customer_name,
|
||||
headline=customer_data.get("persona", "")[:100] if customer_data.get("persona") else None
|
||||
)
|
||||
await db.save_linkedin_profile(linkedin_profile)
|
||||
logger.info("LinkedIn profile saved")
|
||||
|
||||
# Step 4: Scrape ONLY posts using Apify
|
||||
step_num = 4 if post_types_data else 3
|
||||
logger.info(f"Step {step_num}/{total_steps}: Scraping LinkedIn posts")
|
||||
try:
|
||||
raw_posts = await scraper.scrape_posts(linkedin_url, limit=50)
|
||||
parsed_posts = scraper.parse_posts_data(raw_posts)
|
||||
|
||||
linkedin_posts = []
|
||||
for post_data in parsed_posts:
|
||||
post = LinkedInPost(
|
||||
customer_id=customer.id,
|
||||
**post_data
|
||||
)
|
||||
linkedin_posts.append(post)
|
||||
|
||||
if linkedin_posts:
|
||||
await db.save_linkedin_posts(linkedin_posts)
|
||||
logger.info(f"Saved {len(linkedin_posts)} posts")
|
||||
else:
|
||||
logger.warning("No posts scraped")
|
||||
linkedin_posts = []
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to scrape posts: {e}")
|
||||
linkedin_posts = []
|
||||
|
||||
# Step 5: Analyze profile (with manual data + scraped posts)
|
||||
step_num = 5 if post_types_data else 4
|
||||
logger.info(f"Step {step_num}/{total_steps}: Analyzing profile with AI")
|
||||
try:
|
||||
profile_analysis = await self.profile_analyzer.process(
|
||||
profile=linkedin_profile,
|
||||
posts=linkedin_posts,
|
||||
customer_data=customer_data
|
||||
)
|
||||
|
||||
# Save profile analysis
|
||||
from src.database.models import ProfileAnalysis
|
||||
analysis_record = ProfileAnalysis(
|
||||
customer_id=customer.id,
|
||||
writing_style=profile_analysis.get("writing_style", {}),
|
||||
tone_analysis=profile_analysis.get("tone_analysis", {}),
|
||||
topic_patterns=profile_analysis.get("topic_patterns", {}),
|
||||
audience_insights=profile_analysis.get("audience_insights", {}),
|
||||
full_analysis=profile_analysis
|
||||
)
|
||||
await db.save_profile_analysis(analysis_record)
|
||||
logger.info("Profile analysis saved")
|
||||
except Exception as e:
|
||||
logger.error(f"Profile analysis failed: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
# Step 6: Extract topics from posts
|
||||
step_num = 6 if post_types_data else 5
|
||||
logger.info(f"Step {step_num}/{total_steps}: Extracting topics from posts")
|
||||
if linkedin_posts:
|
||||
try:
|
||||
topics = await self.topic_extractor.process(
|
||||
posts=linkedin_posts,
|
||||
customer_id=customer.id # Pass UUID directly
|
||||
)
|
||||
if topics:
|
||||
await db.save_topics(topics)
|
||||
logger.info(f"Extracted and saved {len(topics)} topics")
|
||||
except Exception as e:
|
||||
logger.error(f"Topic extraction failed: {e}", exc_info=True)
|
||||
else:
|
||||
logger.info("No posts to extract topics from")
|
||||
|
||||
# Step 7 & 8: Classify and analyze post types (if post types exist)
|
||||
if created_post_types and linkedin_posts:
|
||||
# Step 7: Classify posts
|
||||
logger.info(f"Step {total_steps - 1}/{total_steps}: Classifying posts by type")
|
||||
try:
|
||||
await self.classify_posts(customer.id)
|
||||
except Exception as e:
|
||||
logger.error(f"Post classification failed: {e}", exc_info=True)
|
||||
|
||||
# Step 8: Analyze post types
|
||||
logger.info(f"Step {total_steps}/{total_steps}: Analyzing post types")
|
||||
try:
|
||||
await self.analyze_post_types(customer.id)
|
||||
except Exception as e:
|
||||
logger.error(f"Post type analysis failed: {e}", exc_info=True)
|
||||
|
||||
logger.info(f"Step {total_steps}/{total_steps}: Initial setup complete!")
|
||||
return customer
|
||||
|
||||
async def classify_posts(self, customer_id: UUID) -> int:
|
||||
"""
|
||||
Classify unclassified posts for a customer.
|
||||
|
||||
Args:
|
||||
customer_id: Customer UUID
|
||||
|
||||
Returns:
|
||||
Number of posts classified
|
||||
"""
|
||||
logger.info(f"=== CLASSIFYING POSTS for customer {customer_id} ===")
|
||||
|
||||
# Get post types
|
||||
post_types = await db.get_post_types(customer_id)
|
||||
if not post_types:
|
||||
logger.info("No post types defined, skipping classification")
|
||||
return 0
|
||||
|
||||
# Get unclassified posts
|
||||
posts = await db.get_unclassified_posts(customer_id)
|
||||
if not posts:
|
||||
logger.info("No unclassified posts found")
|
||||
return 0
|
||||
|
||||
logger.info(f"Classifying {len(posts)} posts into {len(post_types)} types")
|
||||
|
||||
# Run classification
|
||||
classifications = await self.post_classifier.process(posts, post_types)
|
||||
|
||||
if classifications:
|
||||
# Bulk update classifications
|
||||
await db.update_posts_classification_bulk(classifications)
|
||||
logger.info(f"Classified {len(classifications)} posts")
|
||||
return len(classifications)
|
||||
|
||||
return 0
|
||||
|
||||
async def analyze_post_types(self, customer_id: UUID) -> Dict[str, Any]:
|
||||
"""
|
||||
Analyze all post types for a customer.
|
||||
|
||||
Args:
|
||||
customer_id: Customer UUID
|
||||
|
||||
Returns:
|
||||
Dictionary with analysis results per post type
|
||||
"""
|
||||
logger.info(f"=== ANALYZING POST TYPES for customer {customer_id} ===")
|
||||
|
||||
# Get post types
|
||||
post_types = await db.get_post_types(customer_id)
|
||||
if not post_types:
|
||||
logger.info("No post types defined")
|
||||
return {}
|
||||
|
||||
results = {}
|
||||
for post_type in post_types:
|
||||
# Get posts for this type
|
||||
posts = await db.get_posts_by_type(customer_id, post_type.id)
|
||||
|
||||
if len(posts) < self.post_type_analyzer.MIN_POSTS_FOR_ANALYSIS:
|
||||
logger.info(f"Post type '{post_type.name}' has only {len(posts)} posts, skipping analysis")
|
||||
results[str(post_type.id)] = {
|
||||
"skipped": True,
|
||||
"reason": f"Not enough posts ({len(posts)} < {self.post_type_analyzer.MIN_POSTS_FOR_ANALYSIS})"
|
||||
}
|
||||
continue
|
||||
|
||||
# Run analysis
|
||||
logger.info(f"Analyzing post type '{post_type.name}' with {len(posts)} posts")
|
||||
analysis = await self.post_type_analyzer.process(post_type, posts)
|
||||
|
||||
# Save analysis to database
|
||||
if analysis.get("sufficient_data"):
|
||||
await db.update_post_type_analysis(
|
||||
post_type_id=post_type.id,
|
||||
analysis=analysis,
|
||||
analyzed_post_count=len(posts)
|
||||
)
|
||||
|
||||
results[str(post_type.id)] = analysis
|
||||
|
||||
return results
|
||||
|
||||
async def research_new_topics(
|
||||
self,
|
||||
customer_id: UUID,
|
||||
progress_callback: Optional[Callable[[str, int, int], None]] = None,
|
||||
post_type_id: Optional[UUID] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Research new content topics for a customer.
|
||||
|
||||
Args:
|
||||
customer_id: Customer UUID
|
||||
progress_callback: Optional callback(message, current_step, total_steps)
|
||||
post_type_id: Optional post type to target research for
|
||||
|
||||
Returns:
|
||||
List of suggested topics
|
||||
"""
|
||||
logger.info(f"=== RESEARCHING NEW TOPICS for customer {customer_id} ===")
|
||||
|
||||
# Get post type context if specified
|
||||
post_type = None
|
||||
post_type_analysis = None
|
||||
if post_type_id:
|
||||
post_type = await db.get_post_type(post_type_id)
|
||||
if post_type:
|
||||
post_type_analysis = post_type.analysis
|
||||
logger.info(f"Targeting research for post type: {post_type.name}")
|
||||
|
||||
def report_progress(message: str, step: int, total: int = 4):
|
||||
if progress_callback:
|
||||
progress_callback(message, step, total)
|
||||
|
||||
# Step 1: Get profile analysis
|
||||
report_progress("Lade Profil-Analyse...", 1)
|
||||
profile_analysis = await db.get_profile_analysis(customer_id)
|
||||
if not profile_analysis:
|
||||
raise ValueError("Profile analysis not found. Run initial setup first.")
|
||||
|
||||
# Step 2: Get ALL existing topics (from multiple sources to avoid repetition)
|
||||
report_progress("Lade existierende Topics...", 2)
|
||||
existing_topics = set()
|
||||
|
||||
# From topics table
|
||||
existing_topics_records = await db.get_topics(customer_id)
|
||||
for t in existing_topics_records:
|
||||
existing_topics.add(t.title)
|
||||
|
||||
# From previous research results
|
||||
all_research = await db.get_all_research(customer_id)
|
||||
for research in all_research:
|
||||
if research.suggested_topics:
|
||||
for topic in research.suggested_topics:
|
||||
if topic.get("title"):
|
||||
existing_topics.add(topic["title"])
|
||||
|
||||
# From generated posts
|
||||
generated_posts = await db.get_generated_posts(customer_id)
|
||||
for post in generated_posts:
|
||||
if post.topic_title:
|
||||
existing_topics.add(post.topic_title)
|
||||
|
||||
existing_topics = list(existing_topics)
|
||||
logger.info(f"Found {len(existing_topics)} existing topics to avoid")
|
||||
|
||||
# Get customer data
|
||||
customer = await db.get_customer(customer_id)
|
||||
|
||||
# Get example posts to understand the person's actual content style
|
||||
# If post_type_id is specified, only use posts of that type
|
||||
if post_type_id:
|
||||
linkedin_posts = await db.get_posts_by_type(customer_id, post_type_id)
|
||||
else:
|
||||
linkedin_posts = await db.get_linkedin_posts(customer_id)
|
||||
|
||||
example_post_texts = [
|
||||
post.post_text for post in linkedin_posts
|
||||
if post.post_text and len(post.post_text) > 100 # Only substantial posts
|
||||
][:15] # Limit to 15 best examples
|
||||
logger.info(f"Loaded {len(example_post_texts)} example posts for research context")
|
||||
|
||||
# Step 3: Run research
|
||||
report_progress("AI recherchiert neue Topics...", 3)
|
||||
logger.info("Running research with AI")
|
||||
research_results = await self.researcher.process(
|
||||
profile_analysis=profile_analysis.full_analysis,
|
||||
existing_topics=existing_topics,
|
||||
customer_data=customer.metadata,
|
||||
example_posts=example_post_texts,
|
||||
post_type=post_type,
|
||||
post_type_analysis=post_type_analysis
|
||||
)
|
||||
|
||||
# Step 4: Save research results
|
||||
report_progress("Speichere Ergebnisse...", 4)
|
||||
from src.database.models import ResearchResult
|
||||
research_record = ResearchResult(
|
||||
customer_id=customer_id,
|
||||
query=f"New topics for {customer.name}" + (f" ({post_type.name})" if post_type else ""),
|
||||
results={"raw_response": research_results["raw_response"]},
|
||||
suggested_topics=research_results["suggested_topics"],
|
||||
target_post_type_id=post_type_id
|
||||
)
|
||||
await db.save_research_result(research_record)
|
||||
logger.info(f"Research completed with {len(research_results['suggested_topics'])} suggestions")
|
||||
|
||||
return research_results["suggested_topics"]
|
||||
|
||||
async def create_post(
|
||||
self,
|
||||
customer_id: UUID,
|
||||
topic: Dict[str, Any],
|
||||
max_iterations: int = 3,
|
||||
progress_callback: Optional[Callable[[str, int, int, Optional[int], Optional[List], Optional[List]], None]] = None,
|
||||
post_type_id: Optional[UUID] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a LinkedIn post through writer-critic iteration.
|
||||
|
||||
Args:
|
||||
customer_id: Customer UUID
|
||||
topic: Topic dictionary
|
||||
max_iterations: Maximum number of writer-critic iterations
|
||||
progress_callback: Optional callback(message, iteration, max_iterations, score, versions, feedback_list)
|
||||
post_type_id: Optional post type for type-specific writing
|
||||
|
||||
Returns:
|
||||
Dictionary with final post and metadata
|
||||
"""
|
||||
logger.info(f"=== CREATING POST for topic: {topic.get('title')} ===")
|
||||
|
||||
def report_progress(message: str, iteration: int, score: Optional[int] = None,
|
||||
versions: Optional[List] = None, feedback_list: Optional[List] = None):
|
||||
if progress_callback:
|
||||
progress_callback(message, iteration, max_iterations, score, versions, feedback_list)
|
||||
|
||||
# Get profile analysis
|
||||
report_progress("Lade Profil-Analyse...", 0, None, [], [])
|
||||
profile_analysis = await db.get_profile_analysis(customer_id)
|
||||
if not profile_analysis:
|
||||
raise ValueError("Profile analysis not found. Run initial setup first.")
|
||||
|
||||
# Get post type info if specified
|
||||
post_type = None
|
||||
post_type_analysis = None
|
||||
if post_type_id:
|
||||
post_type = await db.get_post_type(post_type_id)
|
||||
if post_type and post_type.analysis:
|
||||
post_type_analysis = post_type.analysis
|
||||
logger.info(f"Using post type '{post_type.name}' for writing")
|
||||
|
||||
# Load customer's real posts as style examples
|
||||
# If post_type_id is specified, only use posts of that type
|
||||
if post_type_id:
|
||||
linkedin_posts = await db.get_posts_by_type(customer_id, post_type_id)
|
||||
if len(linkedin_posts) < 3:
|
||||
# Fall back to all posts if not enough type-specific posts
|
||||
linkedin_posts = await db.get_linkedin_posts(customer_id)
|
||||
logger.info("Not enough type-specific posts, using all posts")
|
||||
else:
|
||||
linkedin_posts = await db.get_linkedin_posts(customer_id)
|
||||
|
||||
example_post_texts = [
|
||||
post.post_text for post in linkedin_posts
|
||||
if post.post_text and len(post.post_text) > 100 # Only use substantial posts
|
||||
]
|
||||
logger.info(f"Loaded {len(example_post_texts)} example posts for style reference")
|
||||
|
||||
# Extract lessons from past feedback (if enabled)
|
||||
feedback_lessons = await self._extract_recurring_feedback(customer_id)
|
||||
|
||||
# Initialize tracking
|
||||
writer_versions = []
|
||||
critic_feedback_list = []
|
||||
current_post = None
|
||||
approved = False
|
||||
iteration = 0
|
||||
|
||||
# Writer-Critic loop
|
||||
while iteration < max_iterations and not approved:
|
||||
iteration += 1
|
||||
logger.info(f"--- Iteration {iteration}/{max_iterations} ---")
|
||||
|
||||
# Writer creates/revises post
|
||||
if iteration == 1:
|
||||
# Initial post
|
||||
report_progress("Writer erstellt ersten Entwurf...", iteration, None, writer_versions, critic_feedback_list)
|
||||
current_post = await self.writer.process(
|
||||
topic=topic,
|
||||
profile_analysis=profile_analysis.full_analysis,
|
||||
example_posts=example_post_texts,
|
||||
learned_lessons=feedback_lessons, # Pass lessons from past feedback
|
||||
post_type=post_type,
|
||||
post_type_analysis=post_type_analysis
|
||||
)
|
||||
else:
|
||||
# Revision based on feedback - pass full critic result for structured changes
|
||||
report_progress("Writer überarbeitet Post...", iteration, None, writer_versions, critic_feedback_list)
|
||||
last_feedback = critic_feedback_list[-1]
|
||||
current_post = await self.writer.process(
|
||||
topic=topic,
|
||||
profile_analysis=profile_analysis.full_analysis,
|
||||
feedback=last_feedback.get("feedback", ""),
|
||||
previous_version=writer_versions[-1],
|
||||
example_posts=example_post_texts,
|
||||
critic_result=last_feedback, # Pass full critic result with specific_changes
|
||||
learned_lessons=feedback_lessons, # Also for revisions
|
||||
post_type=post_type,
|
||||
post_type_analysis=post_type_analysis
|
||||
)
|
||||
|
||||
writer_versions.append(current_post)
|
||||
logger.info(f"Writer produced version {iteration}")
|
||||
|
||||
# Report progress with new version
|
||||
report_progress("Critic bewertet Post...", iteration, None, writer_versions, critic_feedback_list)
|
||||
|
||||
# Critic reviews post with iteration awareness
|
||||
critic_result = await self.critic.process(
|
||||
post=current_post,
|
||||
profile_analysis=profile_analysis.full_analysis,
|
||||
topic=topic,
|
||||
example_posts=example_post_texts,
|
||||
iteration=iteration,
|
||||
max_iterations=max_iterations
|
||||
)
|
||||
critic_feedback_list.append(critic_result)
|
||||
|
||||
approved = critic_result.get("approved", False)
|
||||
score = critic_result.get("overall_score", 0)
|
||||
|
||||
# Auto-approve on last iteration if score is decent (>= 80)
|
||||
if iteration == max_iterations and not approved and score >= 80:
|
||||
approved = True
|
||||
critic_result["approved"] = True
|
||||
logger.info(f"Auto-approved on final iteration with score {score}")
|
||||
|
||||
logger.info(f"Critic score: {score}/100 | Approved: {approved}")
|
||||
|
||||
if approved:
|
||||
report_progress("Post genehmigt!", iteration, score, writer_versions, critic_feedback_list)
|
||||
logger.info("Post approved!")
|
||||
break
|
||||
else:
|
||||
report_progress(f"Score: {score}/100 - Überarbeitung nötig", iteration, score, writer_versions, critic_feedback_list)
|
||||
|
||||
if iteration < max_iterations:
|
||||
logger.info("Post needs revision, continuing...")
|
||||
|
||||
# Determine final status based on score
|
||||
final_score = critic_feedback_list[-1].get("overall_score", 0) if critic_feedback_list else 0
|
||||
if approved and final_score >= 85:
|
||||
status = "approved"
|
||||
elif approved and final_score >= 80:
|
||||
status = "approved" # Auto-approved
|
||||
else:
|
||||
status = "draft"
|
||||
|
||||
# Save generated post
|
||||
from src.database.models import GeneratedPost
|
||||
generated_post = GeneratedPost(
|
||||
customer_id=customer_id,
|
||||
topic_title=topic.get("title", "Unknown"),
|
||||
post_content=current_post,
|
||||
iterations=iteration,
|
||||
writer_versions=writer_versions,
|
||||
critic_feedback=critic_feedback_list,
|
||||
status=status,
|
||||
post_type_id=post_type_id
|
||||
)
|
||||
saved_post = await db.save_generated_post(generated_post)
|
||||
|
||||
logger.info(f"Post creation complete after {iteration} iterations")
|
||||
|
||||
return {
|
||||
"post_id": saved_post.id,
|
||||
"final_post": current_post,
|
||||
"iterations": iteration,
|
||||
"approved": approved,
|
||||
"final_score": critic_feedback_list[-1].get("overall_score", 0) if critic_feedback_list else 0,
|
||||
"writer_versions": writer_versions,
|
||||
"critic_feedback": critic_feedback_list
|
||||
}
|
||||
|
||||
async def _extract_recurring_feedback(self, customer_id: UUID) -> Dict[str, Any]:
|
||||
"""
|
||||
Extract recurring feedback patterns from past generated posts.
|
||||
|
||||
Args:
|
||||
customer_id: Customer UUID
|
||||
|
||||
Returns:
|
||||
Dictionary with recurring improvements and lessons learned
|
||||
"""
|
||||
if not settings.writer_learn_from_feedback:
|
||||
return {"lessons": [], "patterns": {}}
|
||||
|
||||
# Get recent generated posts with their critic feedback
|
||||
generated_posts = await db.get_generated_posts(customer_id)
|
||||
|
||||
if not generated_posts:
|
||||
return {"lessons": [], "patterns": {}}
|
||||
|
||||
# Limit to recent posts
|
||||
recent_posts = generated_posts[:settings.writer_feedback_history_count]
|
||||
|
||||
# Collect all improvements from final feedback
|
||||
all_improvements = []
|
||||
all_scores = []
|
||||
low_score_issues = [] # Issues from posts that scored < 85
|
||||
|
||||
for post in recent_posts:
|
||||
if not post.critic_feedback:
|
||||
continue
|
||||
|
||||
# Get final feedback (last in list)
|
||||
final_feedback = post.critic_feedback[-1] if post.critic_feedback else None
|
||||
if not final_feedback:
|
||||
continue
|
||||
|
||||
score = final_feedback.get("overall_score", 0)
|
||||
all_scores.append(score)
|
||||
|
||||
# Collect improvements
|
||||
improvements = final_feedback.get("improvements", [])
|
||||
all_improvements.extend(improvements)
|
||||
|
||||
# Track issues from lower-scoring posts
|
||||
if score < 85:
|
||||
low_score_issues.extend(improvements)
|
||||
|
||||
if not all_improvements:
|
||||
return {"lessons": [], "patterns": {}}
|
||||
|
||||
# Count frequency of improvements (normalized)
|
||||
def normalize_improvement(text: str) -> str:
|
||||
"""Normalize improvement text for comparison."""
|
||||
text = text.lower().strip()
|
||||
# Remove common prefixes
|
||||
for prefix in ["der ", "die ", "das ", "mehr ", "weniger ", "zu "]:
|
||||
if text.startswith(prefix):
|
||||
text = text[len(prefix):]
|
||||
return text[:50] # Limit length for comparison
|
||||
|
||||
improvement_counts = Counter([normalize_improvement(imp) for imp in all_improvements])
|
||||
low_score_counts = Counter([normalize_improvement(imp) for imp in low_score_issues])
|
||||
|
||||
# Find recurring issues (mentioned 2+ times)
|
||||
recurring_issues = [
|
||||
imp for imp, count in improvement_counts.most_common(10)
|
||||
if count >= 2
|
||||
]
|
||||
|
||||
# Find critical issues (from low-scoring posts, mentioned 2+ times)
|
||||
critical_issues = [
|
||||
imp for imp, count in low_score_counts.most_common(5)
|
||||
if count >= 2
|
||||
]
|
||||
|
||||
# Build lessons learned
|
||||
lessons = []
|
||||
|
||||
if critical_issues:
|
||||
lessons.append({
|
||||
"type": "critical",
|
||||
"message": "Diese Punkte führten zu niedrigen Scores - UNBEDINGT vermeiden:",
|
||||
"items": critical_issues[:3]
|
||||
})
|
||||
|
||||
if recurring_issues:
|
||||
# Filter out critical issues
|
||||
non_critical = [r for r in recurring_issues if r not in critical_issues]
|
||||
if non_critical:
|
||||
lessons.append({
|
||||
"type": "recurring",
|
||||
"message": "Häufig genannte Verbesserungspunkte aus vergangenen Posts:",
|
||||
"items": non_critical[:4]
|
||||
})
|
||||
|
||||
# Calculate average score for context
|
||||
avg_score = sum(all_scores) / len(all_scores) if all_scores else 0
|
||||
|
||||
logger.info(f"Extracted feedback from {len(recent_posts)} posts: {len(lessons)} lesson categories, avg score: {avg_score:.1f}")
|
||||
|
||||
return {
|
||||
"lessons": lessons,
|
||||
"patterns": {
|
||||
"avg_score": avg_score,
|
||||
"posts_analyzed": len(recent_posts),
|
||||
"recurring_count": len(recurring_issues),
|
||||
"critical_count": len(critical_issues)
|
||||
}
|
||||
}
|
||||
|
||||
async def get_customer_status(self, customer_id: UUID) -> Dict[str, Any]:
|
||||
"""
|
||||
Get status information for a customer.
|
||||
|
||||
Args:
|
||||
customer_id: Customer UUID
|
||||
|
||||
Returns:
|
||||
Status dictionary
|
||||
"""
|
||||
customer = await db.get_customer(customer_id)
|
||||
if not customer:
|
||||
raise ValueError("Customer not found")
|
||||
|
||||
profile = await db.get_linkedin_profile(customer_id)
|
||||
posts = await db.get_linkedin_posts(customer_id)
|
||||
analysis = await db.get_profile_analysis(customer_id)
|
||||
generated_posts = await db.get_generated_posts(customer_id)
|
||||
all_research = await db.get_all_research(customer_id)
|
||||
post_types = await db.get_post_types(customer_id)
|
||||
|
||||
# Count total research entries
|
||||
research_count = len(all_research)
|
||||
|
||||
# Count classified posts
|
||||
classified_posts = [p for p in posts if p.post_type_id]
|
||||
|
||||
# Count analyzed post types
|
||||
analyzed_types = [pt for pt in post_types if pt.analysis]
|
||||
|
||||
# Check what's missing
|
||||
missing_items = []
|
||||
if not posts:
|
||||
missing_items.append("LinkedIn Posts (Scraping)")
|
||||
if not analysis:
|
||||
missing_items.append("Profil-Analyse")
|
||||
if research_count == 0:
|
||||
missing_items.append("Research Topics")
|
||||
|
||||
# Ready for posts if we have scraped posts and profile analysis
|
||||
ready_for_posts = len(posts) > 0 and analysis is not None
|
||||
|
||||
return {
|
||||
"has_scraped_posts": len(posts) > 0,
|
||||
"scraped_posts_count": len(posts),
|
||||
"has_profile_analysis": analysis is not None,
|
||||
"research_count": research_count,
|
||||
"posts_count": len(generated_posts),
|
||||
"ready_for_posts": ready_for_posts,
|
||||
"missing_items": missing_items,
|
||||
"post_types_count": len(post_types),
|
||||
"classified_posts_count": len(classified_posts),
|
||||
"analyzed_types_count": len(analyzed_types)
|
||||
}
|
||||
|
||||
|
||||
# Global orchestrator instance
|
||||
orchestrator = WorkflowOrchestrator()
|
||||
4
src/scraper/__init__.py
Normal file
4
src/scraper/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""Scraper module."""
|
||||
from src.scraper.apify_scraper import LinkedInScraper, scraper
|
||||
|
||||
__all__ = ["LinkedInScraper", "scraper"]
|
||||
168
src/scraper/apify_scraper.py
Normal file
168
src/scraper/apify_scraper.py
Normal file
@@ -0,0 +1,168 @@
|
||||
"""LinkedIn posts scraper using Apify (apimaestro~linkedin-profile-posts)."""
|
||||
import asyncio
|
||||
from typing import Dict, Any, List
|
||||
from apify_client import ApifyClient
|
||||
from loguru import logger
|
||||
|
||||
from src.config import settings
|
||||
|
||||
|
||||
class LinkedInScraper:
|
||||
"""LinkedIn posts scraper using Apify."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize Apify client."""
|
||||
self.client = ApifyClient(settings.apify_api_key)
|
||||
logger.info("Apify client initialized")
|
||||
|
||||
async def scrape_posts(self, linkedin_url: str, limit: int = 50) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Scrape posts from a LinkedIn profile.
|
||||
|
||||
Args:
|
||||
linkedin_url: URL of the LinkedIn profile
|
||||
limit: Maximum number of posts to scrape
|
||||
|
||||
Returns:
|
||||
List of post dictionaries
|
||||
"""
|
||||
logger.info(f"Scraping posts from: {linkedin_url}")
|
||||
|
||||
# Extract username from LinkedIn URL
|
||||
# Example: https://www.linkedin.com/in/christinahildebrandt/ -> christinahildebrandt
|
||||
username = self._extract_username_from_url(linkedin_url)
|
||||
logger.info(f"Extracted username: {username}")
|
||||
|
||||
# Prepare the Actor input for apimaestro~linkedin-profile-posts
|
||||
run_input = {
|
||||
"username": username,
|
||||
"page_number": 1,
|
||||
"limit": limit,
|
||||
}
|
||||
|
||||
try:
|
||||
# Run the Actor in thread pool to avoid blocking event loop
|
||||
run = await asyncio.to_thread(
|
||||
self.client.actor(settings.apify_actor_id).call,
|
||||
run_input=run_input
|
||||
)
|
||||
|
||||
# Fetch results from the run's dataset in thread pool
|
||||
dataset_items = await asyncio.to_thread(
|
||||
lambda: list(self.client.dataset(run["defaultDatasetId"]).iterate_items())
|
||||
)
|
||||
|
||||
|
||||
if not dataset_items:
|
||||
logger.warning("No posts found")
|
||||
return []
|
||||
|
||||
logger.info(f"Successfully scraped {len(dataset_items)} posts")
|
||||
return dataset_items
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error scraping posts: {e}")
|
||||
raise
|
||||
|
||||
def _extract_username_from_url(self, linkedin_url: str) -> str:
|
||||
"""
|
||||
Extract username from LinkedIn URL.
|
||||
|
||||
Args:
|
||||
linkedin_url: LinkedIn profile URL
|
||||
|
||||
Returns:
|
||||
Username
|
||||
"""
|
||||
import re
|
||||
|
||||
# Remove trailing slash
|
||||
url = linkedin_url.rstrip('/')
|
||||
|
||||
# Extract username from different LinkedIn URL formats
|
||||
# https://www.linkedin.com/in/username/
|
||||
# https://linkedin.com/in/username
|
||||
# www.linkedin.com/in/username
|
||||
match = re.search(r'/in/([^/]+)', url)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
# If no match, raise error
|
||||
raise ValueError(f"Could not extract username from LinkedIn URL: {linkedin_url}")
|
||||
|
||||
def parse_posts_data(self, raw_posts: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Parse and structure the raw Apify posts data.
|
||||
|
||||
Only includes posts with post_type "regular" (excludes reposts, shared posts, etc.)
|
||||
|
||||
Args:
|
||||
raw_posts: List of raw post data from Apify
|
||||
|
||||
Returns:
|
||||
List of structured post dictionaries
|
||||
"""
|
||||
from datetime import datetime
|
||||
parsed_posts = []
|
||||
skipped_count = 0
|
||||
|
||||
for post in raw_posts:
|
||||
# Only include regular posts (not reposts, shares, etc.)
|
||||
post_type = post.get("post_type", "").lower()
|
||||
if post_type != "regular":
|
||||
skipped_count += 1
|
||||
logger.debug(f"Skipping non-regular post (type: {post_type})")
|
||||
continue
|
||||
# Extract posted_at date
|
||||
posted_at_data = post.get("posted_at", {})
|
||||
post_date = None
|
||||
|
||||
if isinstance(posted_at_data, dict):
|
||||
date_str = posted_at_data.get("date")
|
||||
if date_str:
|
||||
try:
|
||||
# Try to parse the date string
|
||||
# Format: "2026-01-20 07:45:33"
|
||||
post_date = datetime.strptime(date_str, "%Y-%m-%d %H:%M:%S")
|
||||
except (ValueError, TypeError):
|
||||
# If parsing fails, keep as string
|
||||
post_date = date_str
|
||||
|
||||
# Extract stats
|
||||
stats = post.get("stats", {})
|
||||
|
||||
# Create a clean copy of raw_data without datetime objects
|
||||
raw_data_clean = {}
|
||||
for key, value in post.items():
|
||||
if isinstance(value, datetime):
|
||||
raw_data_clean[key] = value.isoformat()
|
||||
elif isinstance(value, dict):
|
||||
# Handle nested dicts
|
||||
raw_data_clean[key] = {}
|
||||
for k, v in value.items():
|
||||
if isinstance(v, datetime):
|
||||
raw_data_clean[key][k] = v.isoformat()
|
||||
else:
|
||||
raw_data_clean[key][k] = v
|
||||
else:
|
||||
raw_data_clean[key] = value
|
||||
|
||||
parsed_post = {
|
||||
"post_url": post.get("url"),
|
||||
"post_text": post.get("text", ""),
|
||||
"post_date": post_date,
|
||||
"likes": stats.get("like", 0) if stats else 0,
|
||||
"comments": stats.get("comments", 0) if stats else 0,
|
||||
"shares": stats.get("reposts", 0) if stats else 0,
|
||||
"raw_data": raw_data_clean
|
||||
}
|
||||
parsed_posts.append(parsed_post)
|
||||
|
||||
if skipped_count > 0:
|
||||
logger.info(f"Skipped {skipped_count} non-regular posts (reposts, shares, etc.)")
|
||||
|
||||
return parsed_posts
|
||||
|
||||
|
||||
# Global scraper instance
|
||||
scraper = LinkedInScraper()
|
||||
4
src/tui/__init__.py
Normal file
4
src/tui/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""TUI module."""
|
||||
from src.tui.app import LinkedInWorkflowApp, run_app
|
||||
|
||||
__all__ = ["LinkedInWorkflowApp", "run_app"]
|
||||
912
src/tui/app.py
Normal file
912
src/tui/app.py
Normal file
@@ -0,0 +1,912 @@
|
||||
"""Main TUI application using Textual."""
|
||||
import threading
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.containers import Container, Horizontal, Vertical, ScrollableContainer
|
||||
from textual.widgets import Header, Footer, Button, Static, Input, Label, TextArea, OptionList, LoadingIndicator, ProgressBar
|
||||
from textual.widgets.option_list import Option
|
||||
from textual.binding import Binding
|
||||
from textual.screen import Screen
|
||||
from textual.worker import Worker, WorkerState
|
||||
from loguru import logger
|
||||
|
||||
from src.orchestrator import orchestrator
|
||||
from src.database import db
|
||||
|
||||
|
||||
class WelcomeScreen(Screen):
|
||||
"""Welcome screen with main menu."""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("q", "quit", "Quit"),
|
||||
]
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Create child widgets."""
|
||||
yield Header()
|
||||
yield Container(
|
||||
Static(
|
||||
"""
|
||||
[bold cyan]Multi-Agent AI Workflow[/]
|
||||
|
||||
|
||||
[yellow]Choose an option:[/]
|
||||
""",
|
||||
id="welcome_text",
|
||||
),
|
||||
Button("🚀 New Customer Setup", id="btn_new_customer", variant="primary"),
|
||||
Button("🔍 Research Topics", id="btn_research", variant="success"),
|
||||
Button("✍️ Create Post", id="btn_create_post", variant="success"),
|
||||
Button("📊 View Status", id="btn_status", variant="default"),
|
||||
Button("❌ Exit", id="btn_exit", variant="error"),
|
||||
id="menu_container",
|
||||
)
|
||||
yield Footer()
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
"""Handle button presses."""
|
||||
button_id = event.button.id
|
||||
|
||||
if button_id == "btn_new_customer":
|
||||
self.app.push_screen(NewCustomerScreen())
|
||||
elif button_id == "btn_research":
|
||||
self.app.push_screen(ResearchScreen())
|
||||
elif button_id == "btn_create_post":
|
||||
self.app.push_screen(CreatePostScreen())
|
||||
elif button_id == "btn_status":
|
||||
self.app.push_screen(StatusScreen())
|
||||
elif button_id == "btn_exit":
|
||||
self.app.exit()
|
||||
|
||||
|
||||
class NewCustomerScreen(Screen):
|
||||
"""Screen for setting up a new customer."""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("escape", "app.pop_screen", "Back"),
|
||||
]
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Create child widgets."""
|
||||
yield Header()
|
||||
yield ScrollableContainer(
|
||||
Static("[bold cyan]═══ New Customer Setup ═══[/]\n", id="title"),
|
||||
|
||||
# Basic Info Section
|
||||
Static("[bold yellow]Basic Information[/]"),
|
||||
Label("Customer Name *:"),
|
||||
Input(placeholder="Enter customer name", id="input_name"),
|
||||
|
||||
Label("LinkedIn URL *:"),
|
||||
Input(placeholder="https://www.linkedin.com/in/username", id="input_linkedin"),
|
||||
|
||||
Label("Company Name:"),
|
||||
Input(placeholder="Enter company name", id="input_company"),
|
||||
|
||||
Label("Email:"),
|
||||
Input(placeholder="customer@example.com", id="input_email"),
|
||||
|
||||
# Persona Section
|
||||
Static("\n[bold yellow]Persona[/]"),
|
||||
Label("Describe the customer's persona, expertise, and positioning:"),
|
||||
TextArea(id="input_persona"),
|
||||
|
||||
# Form of Address
|
||||
Static("\n[bold yellow]Communication Style[/]"),
|
||||
Label("Form of Address:"),
|
||||
Input(placeholder="e.g., Duzen (Du/Euch) or Siezen (Sie)", id="input_address"),
|
||||
|
||||
# Style Guide
|
||||
Label("Style Guide:"),
|
||||
Label("Describe writing style, tone, and guidelines:"),
|
||||
TextArea(id="input_style_guide"),
|
||||
|
||||
# Topic History
|
||||
Static("\n[bold yellow]Content History[/]"),
|
||||
Label("Topic History (comma separated):"),
|
||||
Label("Enter previous topics covered:"),
|
||||
TextArea(id="input_topic_history"),
|
||||
|
||||
# Example Posts
|
||||
Label("Example Posts (separate with --- on new line):"),
|
||||
Label("Paste example posts to analyze writing style:"),
|
||||
TextArea(id="input_example_posts"),
|
||||
|
||||
# Actions
|
||||
Static("\n"),
|
||||
Horizontal(
|
||||
Button("Cancel", id="btn_cancel", variant="error"),
|
||||
Button("Start Setup", id="btn_start", variant="primary"),
|
||||
id="button_row"
|
||||
),
|
||||
|
||||
# Status/Progress area
|
||||
Container(
|
||||
Static("", id="status_message"),
|
||||
id="status_container"
|
||||
),
|
||||
|
||||
id="form_container",
|
||||
)
|
||||
yield Footer()
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
"""Handle button presses."""
|
||||
if event.button.id == "btn_cancel":
|
||||
self.app.pop_screen()
|
||||
elif event.button.id == "btn_start":
|
||||
self.start_setup()
|
||||
|
||||
def start_setup(self) -> None:
|
||||
"""Start the customer setup process."""
|
||||
# Get inputs
|
||||
name = self.query_one("#input_name", Input).value.strip()
|
||||
linkedin_url = self.query_one("#input_linkedin", Input).value.strip()
|
||||
company = self.query_one("#input_company", Input).value.strip()
|
||||
email = self.query_one("#input_email", Input).value.strip()
|
||||
persona = self.query_one("#input_persona", TextArea).text.strip()
|
||||
form_of_address = self.query_one("#input_address", Input).value.strip()
|
||||
style_guide = self.query_one("#input_style_guide", TextArea).text.strip()
|
||||
topic_history_raw = self.query_one("#input_topic_history", TextArea).text.strip()
|
||||
example_posts_raw = self.query_one("#input_example_posts", TextArea).text.strip()
|
||||
|
||||
status_widget = self.query_one("#status_message", Static)
|
||||
|
||||
if not name or not linkedin_url:
|
||||
status_widget.update("[red]✗ Please fill in required fields (Name and LinkedIn URL)[/]")
|
||||
return
|
||||
|
||||
# Parse topic history
|
||||
topic_history = [t.strip() for t in topic_history_raw.split(",") if t.strip()]
|
||||
|
||||
# Parse example posts
|
||||
example_posts = [p.strip() for p in example_posts_raw.split("---") if p.strip()]
|
||||
|
||||
# Disable buttons during setup
|
||||
self.query_one("#btn_start", Button).disabled = True
|
||||
self.query_one("#btn_cancel", Button).disabled = True
|
||||
|
||||
# Show progress steps
|
||||
status_widget.update("[bold cyan]Starting setup process...[/]\n")
|
||||
|
||||
customer_data = {
|
||||
"company_name": company,
|
||||
"email": email,
|
||||
"persona": persona,
|
||||
"form_of_address": form_of_address,
|
||||
"style_guide": style_guide,
|
||||
"topic_history": topic_history,
|
||||
"example_posts": example_posts
|
||||
}
|
||||
|
||||
# Show what's happening
|
||||
status_widget.update(
|
||||
"[bold cyan]⏳ Step 1/5: Creating customer record...[/]\n"
|
||||
"[bold cyan]⏳ Step 2/5: Creating LinkedIn profile...[/]\n"
|
||||
"[bold cyan]⏳ Step 3/5: Scraping LinkedIn posts...[/]\n"
|
||||
"[yellow] This may take 1-2 minutes...[/]"
|
||||
)
|
||||
|
||||
# Run setup in background worker
|
||||
self.run_worker(
|
||||
self._run_setup_worker(linkedin_url, name, customer_data),
|
||||
name="setup_worker",
|
||||
group="setup",
|
||||
exclusive=True
|
||||
)
|
||||
|
||||
async def _run_setup_worker(self, linkedin_url: str, name: str, customer_data: dict):
|
||||
"""Worker method to run setup in background."""
|
||||
return await orchestrator.run_initial_setup(
|
||||
linkedin_url=linkedin_url,
|
||||
customer_name=name,
|
||||
customer_data=customer_data
|
||||
)
|
||||
|
||||
def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
|
||||
"""Handle worker state changes."""
|
||||
if event.worker.name != "setup_worker":
|
||||
return
|
||||
|
||||
status_widget = self.query_one("#status_message", Static)
|
||||
|
||||
if event.state == WorkerState.SUCCESS:
|
||||
# Worker completed successfully
|
||||
customer = event.worker.result
|
||||
status_widget.update(
|
||||
"[bold green]✓ Step 1/5: Customer record created[/]\n"
|
||||
"[bold green]✓ Step 2/5: LinkedIn profile created[/]\n"
|
||||
"[bold green]✓ Step 3/5: LinkedIn posts scraped[/]\n"
|
||||
"[bold green]✓ Step 4/5: Profile analyzed[/]\n"
|
||||
"[bold green]✓ Step 5/5: Topics extracted[/]\n\n"
|
||||
f"[bold cyan]═══ Setup Complete! ═══[/]\n"
|
||||
f"[green]Customer ID: {customer.id}[/]\n"
|
||||
f"[green]Name: {customer.name}[/]\n\n"
|
||||
"[yellow]You can now research topics or create posts.[/]"
|
||||
)
|
||||
logger.info(f"Setup completed for customer: {customer.id}")
|
||||
elif event.state == WorkerState.ERROR:
|
||||
# Worker failed
|
||||
error = event.worker.error
|
||||
logger.exception(f"Setup failed: {error}")
|
||||
status_widget.update(
|
||||
f"[bold red]✗ Setup Failed[/]\n\n"
|
||||
f"[red]Error: {str(error)}[/]\n\n"
|
||||
f"[yellow]Please check the error and try again.[/]"
|
||||
)
|
||||
self.query_one("#btn_start", Button).disabled = False
|
||||
self.query_one("#btn_cancel", Button).disabled = False
|
||||
elif event.state == WorkerState.CANCELLED:
|
||||
# Worker was cancelled
|
||||
status_widget.update("[yellow]Setup cancelled[/]")
|
||||
self.query_one("#btn_start", Button).disabled = False
|
||||
self.query_one("#btn_cancel", Button).disabled = False
|
||||
|
||||
|
||||
class ResearchScreen(Screen):
|
||||
"""Screen for researching new topics."""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("escape", "app.pop_screen", "Back"),
|
||||
]
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Create child widgets."""
|
||||
yield Header()
|
||||
yield Container(
|
||||
Static("[bold cyan]═══ Research New Topics ═══[/]\n"),
|
||||
|
||||
Static("[bold yellow]Select Customer[/]"),
|
||||
Static("Use arrow keys to navigate, Enter to select", id="help_text"),
|
||||
OptionList(id="customer_list"),
|
||||
|
||||
Static("\n"),
|
||||
Button("Start Research", id="btn_research", variant="primary"),
|
||||
|
||||
Static("\n"),
|
||||
Container(
|
||||
Static("", id="progress_status"),
|
||||
ProgressBar(id="progress_bar", total=100, show_eta=False),
|
||||
id="progress_container"
|
||||
),
|
||||
|
||||
ScrollableContainer(
|
||||
Static("", id="research_results"),
|
||||
id="results_container"
|
||||
),
|
||||
|
||||
id="research_container",
|
||||
)
|
||||
yield Footer()
|
||||
|
||||
async def on_mount(self) -> None:
|
||||
"""Load customers when screen mounts."""
|
||||
# Hide progress container initially
|
||||
self.query_one("#progress_container").display = False
|
||||
await self.load_customers()
|
||||
|
||||
async def load_customers(self) -> None:
|
||||
"""Load customer list."""
|
||||
try:
|
||||
customers = await db.list_customers()
|
||||
customer_list = self.query_one("#customer_list", OptionList)
|
||||
|
||||
if customers:
|
||||
for c in customers:
|
||||
customer_list.add_option(
|
||||
Option(f"- {c.name} - {c.company_name or 'No Company'}", id=str(c.id))
|
||||
)
|
||||
self._customers = {str(c.id): c for c in customers}
|
||||
else:
|
||||
self.query_one("#help_text", Static).update(
|
||||
"[yellow]No customers found. Please create a customer first.[/]"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load customers: {e}")
|
||||
self.query_one("#help_text", Static).update(f"[red]Error loading customers: {str(e)}[/]")
|
||||
|
||||
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
||||
"""Handle customer selection."""
|
||||
self._selected_customer_id = event.option.id
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
"""Handle button presses."""
|
||||
if event.button.id == "btn_research":
|
||||
if hasattr(self, "_selected_customer_id") and self._selected_customer_id:
|
||||
self.start_research(self._selected_customer_id)
|
||||
else:
|
||||
results_widget = self.query_one("#research_results", Static)
|
||||
results_widget.update("[yellow]Please select a customer first.[/]")
|
||||
|
||||
def start_research(self, customer_id: str) -> None:
|
||||
"""Start research."""
|
||||
# Clear previous results
|
||||
self.query_one("#research_results", Static).update("")
|
||||
|
||||
# Show progress container
|
||||
self.query_one("#progress_container").display = True
|
||||
self.query_one("#progress_bar", ProgressBar).update(progress=0)
|
||||
self.query_one("#progress_status", Static).update("[bold cyan]Starte Research...[/]")
|
||||
|
||||
# Disable button
|
||||
self.query_one("#btn_research", Button).disabled = True
|
||||
|
||||
# Run research in background worker
|
||||
self.run_worker(
|
||||
self._run_research_worker(customer_id),
|
||||
name="research_worker",
|
||||
group="research",
|
||||
exclusive=True
|
||||
)
|
||||
|
||||
def _update_research_progress(self, message: str, step: int, total: int) -> None:
|
||||
"""Update progress - works from both main thread and worker threads."""
|
||||
def update():
|
||||
progress_pct = (step / total) * 100
|
||||
self.query_one("#progress_bar", ProgressBar).update(progress=progress_pct)
|
||||
self.query_one("#progress_status", Static).update(f"[bold cyan]Step {step}/{total}:[/] {message}")
|
||||
self.refresh()
|
||||
|
||||
# Check if we're on the main thread or a different thread
|
||||
if self.app._thread_id == threading.get_ident():
|
||||
# Same thread - schedule update for next tick to allow UI refresh
|
||||
self.app.call_later(update)
|
||||
else:
|
||||
# Different thread - use call_from_thread
|
||||
self.app.call_from_thread(update)
|
||||
|
||||
async def _run_research_worker(self, customer_id: str):
|
||||
"""Worker method to run research in background."""
|
||||
from uuid import UUID
|
||||
return await orchestrator.research_new_topics(
|
||||
UUID(customer_id),
|
||||
progress_callback=self._update_research_progress
|
||||
)
|
||||
|
||||
def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
|
||||
"""Handle worker state changes."""
|
||||
if event.worker.name != "research_worker":
|
||||
return
|
||||
|
||||
results_widget = self.query_one("#research_results", Static)
|
||||
|
||||
if event.state == WorkerState.SUCCESS:
|
||||
# Worker completed successfully
|
||||
topics = event.worker.result
|
||||
|
||||
# Update progress to 100%
|
||||
self.query_one("#progress_bar", ProgressBar).update(progress=100)
|
||||
self.query_one("#progress_status", Static).update("[bold green]✓ Abgeschlossen![/]")
|
||||
|
||||
# Format results
|
||||
output = "[bold green]✓ Research Complete![/]\n\n"
|
||||
output += f"[bold cyan]Found {len(topics)} new topic suggestions:[/]\n\n"
|
||||
|
||||
for i, topic in enumerate(topics, 1):
|
||||
output += f"[bold]{i}. {topic.get('title', 'Unknown')}[/]\n"
|
||||
output += f" [dim]Category:[/] {topic.get('category', 'N/A')}\n"
|
||||
|
||||
fact = topic.get('fact', '')
|
||||
if fact:
|
||||
if len(fact) > 200:
|
||||
fact = fact[:197] + "..."
|
||||
output += f" [dim]Description:[/] {fact}\n"
|
||||
|
||||
output += "\n"
|
||||
|
||||
output += "[yellow]Topics saved to research results and ready for post creation.[/]"
|
||||
results_widget.update(output)
|
||||
elif event.state == WorkerState.ERROR:
|
||||
# Worker failed
|
||||
error = event.worker.error
|
||||
logger.exception(f"Research failed: {error}")
|
||||
self.query_one("#progress_status", Static).update("[bold red]✗ Fehler![/]")
|
||||
results_widget.update(
|
||||
f"[bold red]✗ Research Failed[/]\n\n"
|
||||
f"[red]Error: {str(error)}[/]\n\n"
|
||||
f"[yellow]Please check the error and try again.[/]"
|
||||
)
|
||||
elif event.state == WorkerState.CANCELLED:
|
||||
# Worker was cancelled
|
||||
results_widget.update("[yellow]Research cancelled[/]")
|
||||
|
||||
# Hide progress container after a moment (keep visible briefly to show completion)
|
||||
# self.query_one("#progress_container").display = False
|
||||
|
||||
# Re-enable button
|
||||
self.query_one("#btn_research", Button).disabled = False
|
||||
|
||||
|
||||
class CreatePostScreen(Screen):
|
||||
"""Screen for creating posts."""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("escape", "app.pop_screen", "Back"),
|
||||
]
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Create child widgets."""
|
||||
yield Header()
|
||||
yield Container(
|
||||
Static("[bold cyan]═══ Create LinkedIn Post ═══[/]\n"),
|
||||
|
||||
# Customer Selection
|
||||
Static("[bold yellow]1. Select Customer[/]"),
|
||||
Static("Use arrow keys to navigate, Enter to select", id="help_customer"),
|
||||
OptionList(id="customer_list"),
|
||||
|
||||
# Topic Selection
|
||||
Static("\n[bold yellow]2. Select Topic[/]"),
|
||||
Static("Select a customer first to load topics...", id="help_topic"),
|
||||
OptionList(id="topic_list"),
|
||||
|
||||
Static("\n"),
|
||||
Button("Create Post", id="btn_create", variant="primary"),
|
||||
|
||||
Static("\n"),
|
||||
Container(
|
||||
Static("", id="progress_status"),
|
||||
ProgressBar(id="progress_bar", total=100, show_eta=False),
|
||||
Static("", id="iteration_info"),
|
||||
id="progress_container"
|
||||
),
|
||||
|
||||
ScrollableContainer(
|
||||
Static("", id="post_output"),
|
||||
id="output_container"
|
||||
),
|
||||
id="create_container",
|
||||
)
|
||||
yield Footer()
|
||||
|
||||
async def on_mount(self) -> None:
|
||||
"""Load data when screen mounts."""
|
||||
# Hide progress container initially
|
||||
self.query_one("#progress_container").display = False
|
||||
await self.load_customers()
|
||||
|
||||
async def load_customers(self) -> None:
|
||||
"""Load customer list."""
|
||||
try:
|
||||
customers = await db.list_customers()
|
||||
customer_list = self.query_one("#customer_list", OptionList)
|
||||
|
||||
if customers:
|
||||
for c in customers:
|
||||
customer_list.add_option(
|
||||
Option(f"- {c.name} - {c.company_name or 'No Company'}", id=str(c.id))
|
||||
)
|
||||
self._customers = {str(c.id): c for c in customers}
|
||||
else:
|
||||
self.query_one("#help_customer", Static).update(
|
||||
"[yellow]No customers found.[/]"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(f"Failed to load customers: {e}")
|
||||
self.query_one("#help_customer", Static).update(
|
||||
f"[red]Error loading customers: {str(e)}[/]"
|
||||
)
|
||||
|
||||
async def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
||||
"""Handle selection from option lists."""
|
||||
if event.option_list.id == "customer_list":
|
||||
# Customer selected
|
||||
self._selected_customer_id = event.option.id
|
||||
customer_name = self._customers[event.option.id].name
|
||||
self.query_one("#help_customer", Static).update(
|
||||
f"[green]✓ Selected: {customer_name}[/]"
|
||||
)
|
||||
# Load topics for this customer
|
||||
await self.load_topics(event.option.id)
|
||||
elif event.option_list.id == "topic_list":
|
||||
# Topic selected
|
||||
self._selected_topic_index = int(event.option.id)
|
||||
topic = self._topics[self._selected_topic_index]
|
||||
self.query_one("#help_topic", Static).update(
|
||||
f"[green]✓ Selected: {topic.get('title', 'Unknown')}[/]"
|
||||
)
|
||||
|
||||
async def load_topics(self, customer_id) -> None:
|
||||
"""Load ALL topics for customer from ALL research results."""
|
||||
try:
|
||||
from uuid import UUID
|
||||
# Get ALL research results, not just the latest
|
||||
all_research = await db.get_all_research(UUID(customer_id))
|
||||
|
||||
topic_list = self.query_one("#topic_list", OptionList)
|
||||
topic_list.clear_options()
|
||||
|
||||
# Collect all topics from all research results
|
||||
all_topics = []
|
||||
for research in all_research:
|
||||
if research.suggested_topics:
|
||||
all_topics.extend(research.suggested_topics)
|
||||
|
||||
if all_topics:
|
||||
self._topics = all_topics
|
||||
|
||||
for i, t in enumerate(all_topics):
|
||||
# Show title and category
|
||||
display_text = f"- {t.get('title', 'Unknown')} [{t.get('category', 'N/A')}]"
|
||||
topic_list.add_option(Option(display_text, id=str(i)))
|
||||
|
||||
self.query_one("#help_topic", Static).update(
|
||||
f"[cyan]{len(all_topics)} topics available from {len(all_research)} research(es) - select one to continue[/]"
|
||||
)
|
||||
else:
|
||||
self.query_one("#help_topic", Static).update(
|
||||
"[yellow]No research topics found. Run research first.[/]"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(f"Failed to load topics: {e}")
|
||||
self.query_one("#help_topic", Static).update(
|
||||
f"[red]Error loading topics: {str(e)}[/]"
|
||||
)
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
"""Handle button presses."""
|
||||
if event.button.id == "btn_create":
|
||||
if not hasattr(self, "_selected_customer_id") or not self._selected_customer_id:
|
||||
output_widget = self.query_one("#post_output", Static)
|
||||
output_widget.update("[yellow]Please select a customer first.[/]")
|
||||
return
|
||||
|
||||
if not hasattr(self, "_selected_topic_index") or self._selected_topic_index is None:
|
||||
output_widget = self.query_one("#post_output", Static)
|
||||
output_widget.update("[yellow]Please select a topic first.[/]")
|
||||
return
|
||||
|
||||
from uuid import UUID
|
||||
topic = self._topics[self._selected_topic_index]
|
||||
self.create_post(UUID(self._selected_customer_id), topic)
|
||||
|
||||
def create_post(self, customer_id, topic) -> None:
|
||||
"""Create a post."""
|
||||
output_widget = self.query_one("#post_output", Static)
|
||||
|
||||
# Clear previous output
|
||||
output_widget.update("")
|
||||
|
||||
# Show progress container
|
||||
self.query_one("#progress_container").display = True
|
||||
self.query_one("#progress_bar", ProgressBar).update(progress=0)
|
||||
self.query_one("#progress_status", Static).update("[bold cyan]Starte Post-Erstellung...[/]")
|
||||
self.query_one("#iteration_info", Static).update("")
|
||||
|
||||
# Disable button
|
||||
self.query_one("#btn_create", Button).disabled = True
|
||||
|
||||
# Run post creation in background worker
|
||||
self.run_worker(
|
||||
self._run_create_post_worker(customer_id, topic),
|
||||
name="create_post_worker",
|
||||
group="create_post",
|
||||
exclusive=True
|
||||
)
|
||||
|
||||
def _update_post_progress(self, message: str, iteration: int, max_iterations: int, score: int = None) -> None:
|
||||
"""Update progress - works from both main thread and worker threads."""
|
||||
def update():
|
||||
# Calculate progress based on iteration
|
||||
if iteration == 0:
|
||||
progress_pct = 0
|
||||
else:
|
||||
progress_pct = (iteration / max_iterations) * 100
|
||||
|
||||
self.query_one("#progress_bar", ProgressBar).update(progress=progress_pct)
|
||||
self.query_one("#progress_status", Static).update(f"[bold cyan]{message}[/]")
|
||||
|
||||
if iteration > 0:
|
||||
score_text = f" | Score: {score}/100" if score else ""
|
||||
self.query_one("#iteration_info", Static).update(
|
||||
f"[dim]Iteration {iteration}/{max_iterations}{score_text}[/]"
|
||||
)
|
||||
self.refresh()
|
||||
|
||||
# Check if we're on the main thread or a different thread
|
||||
if self.app._thread_id == threading.get_ident():
|
||||
# Same thread - schedule update for next tick to allow UI refresh
|
||||
self.app.call_later(update)
|
||||
else:
|
||||
# Different thread - use call_from_thread
|
||||
self.app.call_from_thread(update)
|
||||
|
||||
async def _run_create_post_worker(self, customer_id, topic):
|
||||
"""Worker method to create post in background."""
|
||||
return await orchestrator.create_post(
|
||||
customer_id=customer_id,
|
||||
topic=topic,
|
||||
max_iterations=3,
|
||||
progress_callback=self._update_post_progress
|
||||
)
|
||||
|
||||
def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
|
||||
"""Handle worker state changes."""
|
||||
if event.worker.name != "create_post_worker":
|
||||
return
|
||||
|
||||
output_widget = self.query_one("#post_output", Static)
|
||||
|
||||
if event.state == WorkerState.SUCCESS:
|
||||
# Worker completed successfully
|
||||
result = event.worker.result
|
||||
topic = self._topics[self._selected_topic_index]
|
||||
|
||||
# Update progress to 100%
|
||||
self.query_one("#progress_bar", ProgressBar).update(progress=100)
|
||||
self.query_one("#progress_status", Static).update("[bold green]✓ Post erstellt![/]")
|
||||
self.query_one("#iteration_info", Static).update(
|
||||
f"[green]Final: {result['iterations']} Iterationen | Score: {result['final_score']}/100[/]"
|
||||
)
|
||||
|
||||
# Format output
|
||||
output = f"[bold green]✓ Post Created Successfully![/]\n\n"
|
||||
output += f"[bold cyan]═══ Post Details ═══[/]\n"
|
||||
output += f"[bold]Topic:[/] {topic.get('title', 'Unknown')}\n"
|
||||
output += f"[bold]Iterations:[/] {result['iterations']}\n"
|
||||
output += f"[bold]Final Score:[/] {result['final_score']}/100\n"
|
||||
output += f"[bold]Approved:[/] {'✓ Yes' if result['approved'] else '✗ No (reached max iterations)'}\n\n"
|
||||
|
||||
output += f"[bold cyan]═══ Final Post ═══[/]\n\n"
|
||||
output += f"[white]{result['final_post']}[/]\n\n"
|
||||
|
||||
output += f"[bold cyan]═══════════════════[/]\n"
|
||||
output += f"[yellow]Post saved to database with ID: {result['post_id']}[/]"
|
||||
|
||||
output_widget.update(output)
|
||||
elif event.state == WorkerState.ERROR:
|
||||
# Worker failed
|
||||
error = event.worker.error
|
||||
logger.exception(f"Post creation failed: {error}")
|
||||
self.query_one("#progress_status", Static).update("[bold red]✗ Fehler![/]")
|
||||
output_widget.update(
|
||||
f"[bold red]✗ Post Creation Failed[/]\n\n"
|
||||
f"[red]Error: {str(error)}[/]\n\n"
|
||||
f"[yellow]Please check the error and try again.[/]"
|
||||
)
|
||||
elif event.state == WorkerState.CANCELLED:
|
||||
# Worker was cancelled
|
||||
output_widget.update("[yellow]Post creation cancelled[/]")
|
||||
|
||||
# Re-enable button
|
||||
self.query_one("#btn_create", Button).disabled = False
|
||||
|
||||
|
||||
class StatusScreen(Screen):
|
||||
"""Screen for viewing customer status."""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("escape", "app.pop_screen", "Back"),
|
||||
]
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Create child widgets."""
|
||||
yield Header()
|
||||
yield Container(
|
||||
Static("[bold cyan]═══ Customer Status ═══[/]\n\n"),
|
||||
ScrollableContainer(
|
||||
Static("Loading...", id="status_content"),
|
||||
id="status_scroll"
|
||||
),
|
||||
Static("\n"),
|
||||
Button("Refresh", id="btn_refresh", variant="primary"),
|
||||
)
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Load status when screen mounts."""
|
||||
self.load_status()
|
||||
|
||||
def load_status(self) -> None:
|
||||
"""Load and display status."""
|
||||
status_widget = self.query_one("#status_content", Static)
|
||||
status_widget.update("[yellow]Loading customer data...[/]")
|
||||
|
||||
# Run status loading in background worker
|
||||
self.run_worker(
|
||||
self._run_load_status_worker(),
|
||||
name="load_status_worker",
|
||||
group="status",
|
||||
exclusive=True
|
||||
)
|
||||
|
||||
async def _run_load_status_worker(self):
|
||||
"""Worker method to load status in background."""
|
||||
customers = await db.list_customers()
|
||||
if not customers:
|
||||
return None
|
||||
|
||||
output = ""
|
||||
for customer in customers:
|
||||
status = await orchestrator.get_customer_status(customer.id)
|
||||
|
||||
output += f"[bold cyan]╔═══ {customer.name} ═══╗[/]\n"
|
||||
output += f"[bold]Customer ID:[/] {customer.id}\n"
|
||||
output += f"[bold]LinkedIn:[/] {customer.linkedin_url}\n"
|
||||
output += f"[bold]Company:[/] {customer.company_name or 'N/A'}\n\n"
|
||||
|
||||
output += f"[bold yellow]Status:[/]\n"
|
||||
output += f" Profile: {'[green]✓ Created[/]' if status['has_profile'] else '[red]✗ Missing[/]'}\n"
|
||||
output += f" Analysis: {'[green]✓ Complete[/]' if status['has_analysis'] else '[red]✗ Missing[/]'}\n\n"
|
||||
|
||||
output += f"[bold yellow]Content:[/]\n"
|
||||
output += f" LinkedIn Posts: [cyan]{status['posts_count']}[/]\n"
|
||||
output += f" Extracted Topics: [cyan]{status['topics_count']}[/]\n"
|
||||
output += f" Generated Posts: [cyan]{status['generated_posts_count']}[/]\n"
|
||||
|
||||
output += f"[bold cyan]╚{'═' * (len(customer.name) + 8)}╝[/]\n\n"
|
||||
|
||||
return output
|
||||
|
||||
def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
|
||||
"""Handle worker state changes."""
|
||||
if event.worker.name != "load_status_worker":
|
||||
return
|
||||
|
||||
status_widget = self.query_one("#status_content", Static)
|
||||
|
||||
if event.state == WorkerState.SUCCESS:
|
||||
# Worker completed successfully
|
||||
output = event.worker.result
|
||||
if output is None:
|
||||
status_widget.update(
|
||||
"[yellow]No customers found.[/]\n"
|
||||
"[dim]Create a new customer to get started.[/]"
|
||||
)
|
||||
else:
|
||||
status_widget.update(output)
|
||||
elif event.state == WorkerState.ERROR:
|
||||
# Worker failed
|
||||
error = event.worker.error
|
||||
logger.exception(f"Failed to load status: {error}")
|
||||
status_widget.update(
|
||||
f"[bold red]✗ Error Loading Status[/]\n\n"
|
||||
f"[red]{str(error)}[/]"
|
||||
)
|
||||
elif event.state == WorkerState.CANCELLED:
|
||||
# Worker was cancelled
|
||||
status_widget.update("[yellow]Status loading cancelled[/]")
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
"""Handle button presses."""
|
||||
if event.button.id == "btn_refresh":
|
||||
self.load_status()
|
||||
|
||||
|
||||
class LinkedInWorkflowApp(App):
|
||||
"""Main Textual application."""
|
||||
|
||||
CSS = """
|
||||
Screen {
|
||||
align: center middle;
|
||||
}
|
||||
|
||||
#menu_container {
|
||||
width: 60;
|
||||
height: auto;
|
||||
padding: 2;
|
||||
border: solid $primary;
|
||||
background: $surface;
|
||||
}
|
||||
|
||||
#menu_container Button {
|
||||
width: 100%;
|
||||
margin: 1;
|
||||
}
|
||||
|
||||
#welcome_text {
|
||||
text-align: center;
|
||||
padding: 1;
|
||||
}
|
||||
|
||||
#form_container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 2;
|
||||
}
|
||||
|
||||
#form_container Input, #form_container TextArea {
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#form_container Label {
|
||||
margin-top: 1;
|
||||
color: $text;
|
||||
}
|
||||
|
||||
#form_container TextArea {
|
||||
height: 5;
|
||||
}
|
||||
|
||||
#button_row {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
margin: 1 0;
|
||||
}
|
||||
|
||||
#button_row Button {
|
||||
margin: 0 1;
|
||||
}
|
||||
|
||||
#status_container, #results_container, #output_container {
|
||||
min-height: 10;
|
||||
border: solid $accent;
|
||||
margin: 1 0;
|
||||
padding: 1;
|
||||
}
|
||||
|
||||
#status_scroll {
|
||||
height: 30;
|
||||
border: solid $accent;
|
||||
margin-top: 1;
|
||||
padding: 1;
|
||||
}
|
||||
|
||||
#research_container, #create_container {
|
||||
width: 90;
|
||||
height: auto;
|
||||
padding: 2;
|
||||
border: solid $primary;
|
||||
background: $surface;
|
||||
}
|
||||
|
||||
#customer_list, #topic_list {
|
||||
height: 10;
|
||||
border: solid $accent;
|
||||
margin: 1 0;
|
||||
}
|
||||
|
||||
#customer_list > .option-list--option,
|
||||
#topic_list > .option-list--option {
|
||||
padding: 1 1;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#help_text, #help_customer, #help_topic {
|
||||
color: $text-muted;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#progress_container {
|
||||
height: auto;
|
||||
padding: 1;
|
||||
margin: 1 0;
|
||||
border: solid $accent;
|
||||
background: $surface-darken-1;
|
||||
}
|
||||
|
||||
#progress_bar {
|
||||
width: 100%;
|
||||
margin: 1 0;
|
||||
}
|
||||
|
||||
#progress_status {
|
||||
text-align: center;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#iteration_info {
|
||||
text-align: center;
|
||||
margin-top: 1;
|
||||
}
|
||||
|
||||
#title {
|
||||
text-align: center;
|
||||
padding: 1;
|
||||
}
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("q", "quit", "Quit", show=True),
|
||||
]
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Set up the application on mount."""
|
||||
self.title = "LinkedIn Post Creation System"
|
||||
self.sub_title = "Multi-Agent AI Workflow"
|
||||
self.push_screen(WelcomeScreen())
|
||||
|
||||
|
||||
def run_app():
|
||||
"""Run the TUI application."""
|
||||
app = LinkedInWorkflowApp()
|
||||
app.run()
|
||||
1
src/web/__init__.py
Normal file
1
src/web/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Web frontend package."""
|
||||
4
src/web/admin/__init__.py
Normal file
4
src/web/admin/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""Admin panel module."""
|
||||
from src.web.admin.routes import admin_router
|
||||
|
||||
__all__ = ["admin_router"]
|
||||
32
src/web/admin/auth.py
Normal file
32
src/web/admin/auth.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""Admin authentication (password-based)."""
|
||||
import hashlib
|
||||
import secrets
|
||||
from fastapi import Request, HTTPException
|
||||
|
||||
from src.config import settings
|
||||
|
||||
# Authentication
|
||||
WEB_PASSWORD = settings.web_password
|
||||
SESSION_SECRET = settings.session_secret or secrets.token_hex(32)
|
||||
AUTH_COOKIE_NAME = "linkedin_admin_auth"
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Hash password with session secret."""
|
||||
return hashlib.sha256(f"{password}{SESSION_SECRET}".encode()).hexdigest()
|
||||
|
||||
|
||||
def verify_auth(request: Request) -> bool:
|
||||
"""Check if request is authenticated for admin."""
|
||||
if not WEB_PASSWORD:
|
||||
return True # No password set, allow access
|
||||
cookie = request.cookies.get(AUTH_COOKIE_NAME)
|
||||
if not cookie:
|
||||
return False
|
||||
return cookie == hash_password(WEB_PASSWORD)
|
||||
|
||||
|
||||
async def require_auth(request: Request):
|
||||
"""Dependency to require admin authentication."""
|
||||
if not verify_auth(request):
|
||||
raise HTTPException(status_code=302, headers={"Location": "/admin/login"})
|
||||
693
src/web/admin/routes.py
Normal file
693
src/web/admin/routes.py
Normal file
@@ -0,0 +1,693 @@
|
||||
"""Admin panel routes (password-protected)."""
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Request, Form, BackgroundTasks, HTTPException
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from pydantic import BaseModel
|
||||
from loguru import logger
|
||||
|
||||
from src.config import settings
|
||||
from src.database import db
|
||||
from src.orchestrator import orchestrator
|
||||
from src.email_service import email_service
|
||||
from src.web.admin.auth import (
|
||||
WEB_PASSWORD, AUTH_COOKIE_NAME, hash_password, verify_auth
|
||||
)
|
||||
from src.web.user.auth import UserSession, set_user_session
|
||||
|
||||
# Router with /admin prefix
|
||||
admin_router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
|
||||
# Templates
|
||||
templates = Jinja2Templates(directory=Path(__file__).parent.parent / "templates" / "admin")
|
||||
|
||||
# Store for progress updates
|
||||
progress_store = {}
|
||||
|
||||
|
||||
async def get_customer_profile_picture(customer_id: UUID) -> Optional[str]:
|
||||
"""Get profile picture URL from customer's LinkedIn posts."""
|
||||
linkedin_posts = await db.get_linkedin_posts(customer_id)
|
||||
for lp in linkedin_posts:
|
||||
if lp.raw_data and isinstance(lp.raw_data, dict):
|
||||
author = lp.raw_data.get("author", {})
|
||||
if author and isinstance(author, dict):
|
||||
profile_picture_url = author.get("profile_picture")
|
||||
if profile_picture_url:
|
||||
return profile_picture_url
|
||||
return None
|
||||
|
||||
|
||||
# ==================== AUTH ROUTES ====================
|
||||
|
||||
@admin_router.get("/login", response_class=HTMLResponse)
|
||||
async def login_page(request: Request, error: str = None):
|
||||
"""Admin login page."""
|
||||
if not WEB_PASSWORD:
|
||||
return RedirectResponse(url="/admin", status_code=302)
|
||||
if verify_auth(request):
|
||||
return RedirectResponse(url="/admin", status_code=302)
|
||||
return templates.TemplateResponse("login.html", {
|
||||
"request": request,
|
||||
"error": error
|
||||
})
|
||||
|
||||
|
||||
@admin_router.post("/login")
|
||||
async def login(request: Request, password: str = Form(...)):
|
||||
"""Handle admin login."""
|
||||
if password == WEB_PASSWORD:
|
||||
response = RedirectResponse(url="/admin", status_code=302)
|
||||
response.set_cookie(
|
||||
key=AUTH_COOKIE_NAME,
|
||||
value=hash_password(WEB_PASSWORD),
|
||||
httponly=True,
|
||||
max_age=60 * 60 * 24 * 7,
|
||||
samesite="lax"
|
||||
)
|
||||
return response
|
||||
return RedirectResponse(url="/admin/login?error=invalid", status_code=302)
|
||||
|
||||
|
||||
@admin_router.get("/logout")
|
||||
async def logout():
|
||||
"""Handle admin logout."""
|
||||
response = RedirectResponse(url="/admin/login", status_code=302)
|
||||
response.delete_cookie(AUTH_COOKIE_NAME)
|
||||
return response
|
||||
|
||||
|
||||
@admin_router.get("/impersonate/{customer_id}")
|
||||
async def impersonate_user(request: Request, customer_id: UUID):
|
||||
"""Login as a user without OAuth (for testing).
|
||||
|
||||
Creates a user session for the given customer and redirects to the user dashboard.
|
||||
Only accessible by authenticated admins.
|
||||
"""
|
||||
if not verify_auth(request):
|
||||
return RedirectResponse(url="/admin/login", status_code=302)
|
||||
|
||||
try:
|
||||
customer = await db.get_customer(customer_id)
|
||||
if not customer:
|
||||
raise HTTPException(status_code=404, detail="Customer not found")
|
||||
|
||||
# Extract vanity name from LinkedIn URL if available
|
||||
linkedin_vanity = ""
|
||||
if customer.linkedin_url:
|
||||
import re
|
||||
match = re.search(r'linkedin\.com/in/([^/?]+)', customer.linkedin_url)
|
||||
if match:
|
||||
linkedin_vanity = match.group(1)
|
||||
|
||||
# Get profile picture
|
||||
profile_picture = await get_customer_profile_picture(customer_id)
|
||||
|
||||
# Create user session
|
||||
session = UserSession(
|
||||
customer_id=str(customer.id),
|
||||
customer_name=customer.name,
|
||||
linkedin_vanity_name=linkedin_vanity or customer.name.lower().replace(" ", "-"),
|
||||
linkedin_name=customer.name,
|
||||
linkedin_picture=profile_picture,
|
||||
email=customer.email
|
||||
)
|
||||
|
||||
# Redirect to user dashboard with session cookie
|
||||
response = RedirectResponse(url="/", status_code=302)
|
||||
set_user_session(response, session)
|
||||
return response
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error impersonating user: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ==================== PAGES ====================
|
||||
|
||||
@admin_router.get("", response_class=HTMLResponse)
|
||||
@admin_router.get("/", response_class=HTMLResponse)
|
||||
async def home(request: Request):
|
||||
"""Admin dashboard."""
|
||||
if not verify_auth(request):
|
||||
return RedirectResponse(url="/admin/login", status_code=302)
|
||||
try:
|
||||
customers = await db.list_customers()
|
||||
total_posts = 0
|
||||
for customer in customers:
|
||||
posts = await db.get_generated_posts(customer.id)
|
||||
total_posts += len(posts)
|
||||
|
||||
return templates.TemplateResponse("dashboard.html", {
|
||||
"request": request,
|
||||
"page": "home",
|
||||
"customers_count": len(customers),
|
||||
"total_posts": total_posts
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading dashboard: {e}")
|
||||
return templates.TemplateResponse("dashboard.html", {
|
||||
"request": request,
|
||||
"page": "home",
|
||||
"error": str(e)
|
||||
})
|
||||
|
||||
|
||||
@admin_router.get("/customers/new", response_class=HTMLResponse)
|
||||
async def new_customer_page(request: Request):
|
||||
"""New customer setup page."""
|
||||
if not verify_auth(request):
|
||||
return RedirectResponse(url="/admin/login", status_code=302)
|
||||
return templates.TemplateResponse("new_customer.html", {
|
||||
"request": request,
|
||||
"page": "new_customer"
|
||||
})
|
||||
|
||||
|
||||
@admin_router.get("/research", response_class=HTMLResponse)
|
||||
async def research_page(request: Request):
|
||||
"""Research topics page."""
|
||||
if not verify_auth(request):
|
||||
return RedirectResponse(url="/admin/login", status_code=302)
|
||||
customers = await db.list_customers()
|
||||
return templates.TemplateResponse("research.html", {
|
||||
"request": request,
|
||||
"page": "research",
|
||||
"customers": customers
|
||||
})
|
||||
|
||||
|
||||
@admin_router.get("/create", response_class=HTMLResponse)
|
||||
async def create_post_page(request: Request):
|
||||
"""Create post page."""
|
||||
if not verify_auth(request):
|
||||
return RedirectResponse(url="/admin/login", status_code=302)
|
||||
customers = await db.list_customers()
|
||||
return templates.TemplateResponse("create_post.html", {
|
||||
"request": request,
|
||||
"page": "create",
|
||||
"customers": customers
|
||||
})
|
||||
|
||||
|
||||
@admin_router.get("/posts", response_class=HTMLResponse)
|
||||
async def posts_page(request: Request):
|
||||
"""View all posts page."""
|
||||
if not verify_auth(request):
|
||||
return RedirectResponse(url="/admin/login", status_code=302)
|
||||
try:
|
||||
customers = await db.list_customers()
|
||||
customers_with_posts = []
|
||||
|
||||
for customer in customers:
|
||||
posts = await db.get_generated_posts(customer.id)
|
||||
profile_picture = await get_customer_profile_picture(customer.id)
|
||||
customers_with_posts.append({
|
||||
"customer": customer,
|
||||
"posts": posts,
|
||||
"post_count": len(posts),
|
||||
"profile_picture": profile_picture
|
||||
})
|
||||
|
||||
return templates.TemplateResponse("posts.html", {
|
||||
"request": request,
|
||||
"page": "posts",
|
||||
"customers_with_posts": customers_with_posts,
|
||||
"total_posts": sum(c["post_count"] for c in customers_with_posts)
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading posts: {e}")
|
||||
return templates.TemplateResponse("posts.html", {
|
||||
"request": request,
|
||||
"page": "posts",
|
||||
"customers_with_posts": [],
|
||||
"total_posts": 0,
|
||||
"error": str(e)
|
||||
})
|
||||
|
||||
|
||||
@admin_router.get("/posts/{post_id}", response_class=HTMLResponse)
|
||||
async def post_detail_page(request: Request, post_id: str):
|
||||
"""Detailed view of a single post."""
|
||||
if not verify_auth(request):
|
||||
return RedirectResponse(url="/admin/login", status_code=302)
|
||||
try:
|
||||
post = await db.get_generated_post(UUID(post_id))
|
||||
if not post:
|
||||
return RedirectResponse(url="/admin/posts", status_code=302)
|
||||
|
||||
customer = await db.get_customer(post.customer_id)
|
||||
linkedin_posts = await db.get_linkedin_posts(post.customer_id)
|
||||
reference_posts = [p.post_text for p in linkedin_posts if p.post_text and len(p.post_text) > 100][:10]
|
||||
|
||||
profile_picture_url = None
|
||||
for lp in linkedin_posts:
|
||||
if lp.raw_data and isinstance(lp.raw_data, dict):
|
||||
author = lp.raw_data.get("author", {})
|
||||
if author and isinstance(author, dict):
|
||||
profile_picture_url = author.get("profile_picture")
|
||||
if profile_picture_url:
|
||||
break
|
||||
|
||||
profile_analysis_record = await db.get_profile_analysis(post.customer_id)
|
||||
profile_analysis = profile_analysis_record.full_analysis if profile_analysis_record else None
|
||||
|
||||
post_type = None
|
||||
post_type_analysis = None
|
||||
if post.post_type_id:
|
||||
post_type = await db.get_post_type(post.post_type_id)
|
||||
if post_type and post_type.analysis:
|
||||
post_type_analysis = post_type.analysis
|
||||
|
||||
final_feedback = None
|
||||
if post.critic_feedback and len(post.critic_feedback) > 0:
|
||||
final_feedback = post.critic_feedback[-1]
|
||||
|
||||
return templates.TemplateResponse("post_detail.html", {
|
||||
"request": request,
|
||||
"page": "posts",
|
||||
"post": post,
|
||||
"customer": customer,
|
||||
"reference_posts": reference_posts,
|
||||
"profile_analysis": profile_analysis,
|
||||
"post_type": post_type,
|
||||
"post_type_analysis": post_type_analysis,
|
||||
"final_feedback": final_feedback,
|
||||
"profile_picture_url": profile_picture_url
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading post detail: {e}")
|
||||
return RedirectResponse(url="/admin/posts", status_code=302)
|
||||
|
||||
|
||||
@admin_router.get("/status", response_class=HTMLResponse)
|
||||
async def status_page(request: Request):
|
||||
"""Customer status page."""
|
||||
if not verify_auth(request):
|
||||
return RedirectResponse(url="/admin/login", status_code=302)
|
||||
try:
|
||||
customers = await db.list_customers()
|
||||
customer_statuses = []
|
||||
|
||||
for customer in customers:
|
||||
status = await orchestrator.get_customer_status(customer.id)
|
||||
profile_picture = await get_customer_profile_picture(customer.id)
|
||||
customer_statuses.append({
|
||||
"customer": customer,
|
||||
"status": status,
|
||||
"profile_picture": profile_picture
|
||||
})
|
||||
|
||||
return templates.TemplateResponse("status.html", {
|
||||
"request": request,
|
||||
"page": "status",
|
||||
"customer_statuses": customer_statuses
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading status: {e}")
|
||||
return templates.TemplateResponse("status.html", {
|
||||
"request": request,
|
||||
"page": "status",
|
||||
"customer_statuses": [],
|
||||
"error": str(e)
|
||||
})
|
||||
|
||||
|
||||
@admin_router.get("/scraped-posts", response_class=HTMLResponse)
|
||||
async def scraped_posts_page(request: Request):
|
||||
"""Manage scraped LinkedIn posts."""
|
||||
if not verify_auth(request):
|
||||
return RedirectResponse(url="/admin/login", status_code=302)
|
||||
customers = await db.list_customers()
|
||||
return templates.TemplateResponse("scraped_posts.html", {
|
||||
"request": request,
|
||||
"page": "scraped_posts",
|
||||
"customers": customers
|
||||
})
|
||||
|
||||
|
||||
# ==================== API ENDPOINTS ====================
|
||||
|
||||
@admin_router.post("/api/customers")
|
||||
async def create_customer(
|
||||
background_tasks: BackgroundTasks,
|
||||
name: str = Form(...),
|
||||
linkedin_url: str = Form(...),
|
||||
company_name: str = Form(None),
|
||||
email: str = Form(None),
|
||||
persona: str = Form(None),
|
||||
form_of_address: str = Form(None),
|
||||
style_guide: str = Form(None),
|
||||
post_types_json: str = Form(None)
|
||||
):
|
||||
"""Create a new customer and run initial setup."""
|
||||
task_id = f"setup_{name}_{asyncio.get_event_loop().time()}"
|
||||
progress_store[task_id] = {"status": "starting", "message": "Starte Setup...", "progress": 0}
|
||||
|
||||
customer_data = {
|
||||
"company_name": company_name,
|
||||
"email": email,
|
||||
"persona": persona,
|
||||
"form_of_address": form_of_address,
|
||||
"style_guide": style_guide,
|
||||
"topic_history": [],
|
||||
"example_posts": []
|
||||
}
|
||||
|
||||
post_types_data = None
|
||||
if post_types_json:
|
||||
try:
|
||||
post_types_data = json.loads(post_types_json)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("Failed to parse post_types_json")
|
||||
|
||||
async def run_setup():
|
||||
try:
|
||||
progress_store[task_id] = {"status": "running", "message": "Erstelle Kunde...", "progress": 10}
|
||||
await asyncio.sleep(0.1)
|
||||
progress_store[task_id] = {"status": "running", "message": "Scrape LinkedIn Posts...", "progress": 30}
|
||||
|
||||
customer = await orchestrator.run_initial_setup(
|
||||
linkedin_url=linkedin_url,
|
||||
customer_name=name,
|
||||
customer_data=customer_data,
|
||||
post_types_data=post_types_data
|
||||
)
|
||||
|
||||
progress_store[task_id] = {
|
||||
"status": "completed",
|
||||
"message": "Setup abgeschlossen!",
|
||||
"progress": 100,
|
||||
"customer_id": str(customer.id)
|
||||
}
|
||||
except Exception as e:
|
||||
logger.exception(f"Setup failed: {e}")
|
||||
progress_store[task_id] = {"status": "error", "message": str(e), "progress": 0}
|
||||
|
||||
background_tasks.add_task(run_setup)
|
||||
return {"task_id": task_id}
|
||||
|
||||
|
||||
@admin_router.get("/api/tasks/{task_id}")
|
||||
async def get_task_status(task_id: str):
|
||||
"""Get task progress."""
|
||||
return progress_store.get(task_id, {"status": "unknown", "message": "Task not found"})
|
||||
|
||||
|
||||
@admin_router.get("/api/customers/{customer_id}/post-types")
|
||||
async def get_customer_post_types(customer_id: str):
|
||||
"""Get post types for a customer."""
|
||||
try:
|
||||
post_types = await db.get_post_types(UUID(customer_id))
|
||||
return {
|
||||
"post_types": [
|
||||
{
|
||||
"id": str(pt.id),
|
||||
"name": pt.name,
|
||||
"description": pt.description,
|
||||
"identifying_hashtags": pt.identifying_hashtags,
|
||||
"identifying_keywords": pt.identifying_keywords,
|
||||
"semantic_properties": pt.semantic_properties,
|
||||
"has_analysis": pt.analysis is not None,
|
||||
"analyzed_post_count": pt.analyzed_post_count,
|
||||
"is_active": pt.is_active
|
||||
}
|
||||
for pt in post_types
|
||||
]
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading post types: {e}")
|
||||
return {"post_types": [], "error": str(e)}
|
||||
|
||||
|
||||
@admin_router.get("/api/customers/{customer_id}/linkedin-posts")
|
||||
async def get_customer_linkedin_posts(customer_id: str):
|
||||
"""Get all scraped LinkedIn posts for a customer."""
|
||||
try:
|
||||
posts = await db.get_linkedin_posts(UUID(customer_id))
|
||||
result_posts = []
|
||||
for post in posts:
|
||||
try:
|
||||
result_posts.append({
|
||||
"id": str(post.id),
|
||||
"post_text": post.post_text,
|
||||
"post_url": post.post_url,
|
||||
"posted_at": post.post_date.isoformat() if post.post_date else None,
|
||||
"engagement_score": (post.likes or 0) + (post.comments or 0) + (post.shares or 0),
|
||||
"likes": post.likes,
|
||||
"comments": post.comments,
|
||||
"shares": post.shares,
|
||||
"post_type_id": str(post.post_type_id) if post.post_type_id else None,
|
||||
"classification_method": post.classification_method,
|
||||
"classification_confidence": post.classification_confidence
|
||||
})
|
||||
except Exception as post_error:
|
||||
logger.error(f"Error processing post {post.id}: {post_error}")
|
||||
return {"posts": result_posts, "total": len(result_posts)}
|
||||
except Exception as e:
|
||||
logger.exception(f"Error loading LinkedIn posts: {e}")
|
||||
return {"posts": [], "total": 0, "error": str(e)}
|
||||
|
||||
|
||||
class ClassifyPostRequest(BaseModel):
|
||||
post_type_id: Optional[str] = None
|
||||
|
||||
|
||||
@admin_router.patch("/api/linkedin-posts/{post_id}/classify")
|
||||
async def classify_linkedin_post(post_id: str, request: ClassifyPostRequest):
|
||||
"""Manually classify a LinkedIn post."""
|
||||
try:
|
||||
if request.post_type_id:
|
||||
await db.update_post_classification(
|
||||
post_id=UUID(post_id),
|
||||
post_type_id=UUID(request.post_type_id),
|
||||
classification_method="manual",
|
||||
classification_confidence=1.0
|
||||
)
|
||||
else:
|
||||
await asyncio.to_thread(
|
||||
lambda: db.client.table("linkedin_posts").update({
|
||||
"post_type_id": None,
|
||||
"classification_method": None,
|
||||
"classification_confidence": None
|
||||
}).eq("id", post_id).execute()
|
||||
)
|
||||
return {"success": True, "post_id": post_id}
|
||||
except Exception as e:
|
||||
logger.error(f"Error classifying post: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@admin_router.post("/api/customers/{customer_id}/classify-posts")
|
||||
async def classify_customer_posts(customer_id: str, background_tasks: BackgroundTasks):
|
||||
"""Trigger post classification for a customer."""
|
||||
task_id = f"classify_{customer_id}_{asyncio.get_event_loop().time()}"
|
||||
progress_store[task_id] = {"status": "starting", "message": "Starte Klassifizierung...", "progress": 0}
|
||||
|
||||
async def run_classification():
|
||||
try:
|
||||
progress_store[task_id] = {"status": "running", "message": "Klassifiziere Posts...", "progress": 50}
|
||||
count = await orchestrator.classify_posts(UUID(customer_id))
|
||||
progress_store[task_id] = {
|
||||
"status": "completed",
|
||||
"message": f"{count} Posts klassifiziert",
|
||||
"progress": 100,
|
||||
"classified_count": count
|
||||
}
|
||||
except Exception as e:
|
||||
logger.exception(f"Classification failed: {e}")
|
||||
progress_store[task_id] = {"status": "error", "message": str(e), "progress": 0}
|
||||
|
||||
background_tasks.add_task(run_classification)
|
||||
return {"task_id": task_id}
|
||||
|
||||
|
||||
@admin_router.post("/api/customers/{customer_id}/analyze-post-types")
|
||||
async def analyze_customer_post_types(customer_id: str, background_tasks: BackgroundTasks):
|
||||
"""Trigger post type analysis for a customer."""
|
||||
task_id = f"analyze_{customer_id}_{asyncio.get_event_loop().time()}"
|
||||
progress_store[task_id] = {"status": "starting", "message": "Starte Analyse...", "progress": 0}
|
||||
|
||||
async def run_analysis():
|
||||
try:
|
||||
progress_store[task_id] = {"status": "running", "message": "Analysiere Post-Typen...", "progress": 50}
|
||||
results = await orchestrator.analyze_post_types(UUID(customer_id))
|
||||
analyzed_count = sum(1 for r in results.values() if r.get("sufficient_data"))
|
||||
progress_store[task_id] = {
|
||||
"status": "completed",
|
||||
"message": f"{analyzed_count} Post-Typen analysiert",
|
||||
"progress": 100,
|
||||
"results": results
|
||||
}
|
||||
except Exception as e:
|
||||
logger.exception(f"Analysis failed: {e}")
|
||||
progress_store[task_id] = {"status": "error", "message": str(e), "progress": 0}
|
||||
|
||||
background_tasks.add_task(run_analysis)
|
||||
return {"task_id": task_id}
|
||||
|
||||
|
||||
@admin_router.get("/api/customers/{customer_id}/topics")
|
||||
async def get_customer_topics(customer_id: str, include_used: bool = False, post_type_id: str = None):
|
||||
"""Get research topics for a customer."""
|
||||
try:
|
||||
if post_type_id:
|
||||
all_research = await db.get_all_research(UUID(customer_id), UUID(post_type_id))
|
||||
else:
|
||||
all_research = await db.get_all_research(UUID(customer_id))
|
||||
|
||||
used_topic_titles = set()
|
||||
if not include_used:
|
||||
generated_posts = await db.get_generated_posts(UUID(customer_id))
|
||||
for post in generated_posts:
|
||||
if post.topic_title:
|
||||
used_topic_titles.add(post.topic_title.lower().strip())
|
||||
|
||||
all_topics = []
|
||||
for research in all_research:
|
||||
if research.suggested_topics:
|
||||
for topic in research.suggested_topics:
|
||||
topic_title = topic.get("title", "").lower().strip()
|
||||
if topic_title in used_topic_titles:
|
||||
continue
|
||||
topic["research_id"] = str(research.id)
|
||||
topic["target_post_type_id"] = str(research.target_post_type_id) if research.target_post_type_id else None
|
||||
all_topics.append(topic)
|
||||
|
||||
return {"topics": all_topics, "used_count": len(used_topic_titles), "available_count": len(all_topics)}
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading topics: {e}")
|
||||
return {"topics": [], "error": str(e)}
|
||||
|
||||
|
||||
@admin_router.post("/api/research")
|
||||
async def start_research(background_tasks: BackgroundTasks, customer_id: str = Form(...), post_type_id: str = Form(None)):
|
||||
"""Start research for a customer."""
|
||||
task_id = f"research_{customer_id}_{asyncio.get_event_loop().time()}"
|
||||
progress_store[task_id] = {"status": "starting", "message": "Starte Recherche...", "progress": 0}
|
||||
|
||||
async def run_research():
|
||||
try:
|
||||
def progress_callback(message: str, step: int, total: int):
|
||||
progress_store[task_id] = {"status": "running", "message": message, "progress": int((step / total) * 100)}
|
||||
|
||||
topics = await orchestrator.research_new_topics(
|
||||
UUID(customer_id),
|
||||
progress_callback=progress_callback,
|
||||
post_type_id=UUID(post_type_id) if post_type_id else None
|
||||
)
|
||||
progress_store[task_id] = {"status": "completed", "message": f"{len(topics)} Topics gefunden!", "progress": 100, "topics": topics}
|
||||
except Exception as e:
|
||||
logger.exception(f"Research failed: {e}")
|
||||
progress_store[task_id] = {"status": "error", "message": str(e), "progress": 0}
|
||||
|
||||
background_tasks.add_task(run_research)
|
||||
return {"task_id": task_id}
|
||||
|
||||
|
||||
@admin_router.post("/api/posts")
|
||||
async def create_post(background_tasks: BackgroundTasks, customer_id: str = Form(...), topic_json: str = Form(...), post_type_id: str = Form(None)):
|
||||
"""Create a new post."""
|
||||
task_id = f"post_{customer_id}_{asyncio.get_event_loop().time()}"
|
||||
progress_store[task_id] = {"status": "starting", "message": "Starte Post-Erstellung...", "progress": 0}
|
||||
topic = json.loads(topic_json)
|
||||
|
||||
async def run_create_post():
|
||||
try:
|
||||
def progress_callback(message: str, iteration: int, max_iterations: int, score: int = None, versions: list = None, feedback_list: list = None):
|
||||
progress = int((iteration / max_iterations) * 100) if iteration > 0 else 5
|
||||
score_text = f" (Score: {score}/100)" if score else ""
|
||||
progress_store[task_id] = {
|
||||
"status": "running", "message": f"{message}{score_text}", "progress": progress,
|
||||
"iteration": iteration, "max_iterations": max_iterations,
|
||||
"versions": versions or [], "feedback_list": feedback_list or []
|
||||
}
|
||||
|
||||
result = await orchestrator.create_post(
|
||||
customer_id=UUID(customer_id), topic=topic, max_iterations=3,
|
||||
progress_callback=progress_callback,
|
||||
post_type_id=UUID(post_type_id) if post_type_id else None
|
||||
)
|
||||
progress_store[task_id] = {
|
||||
"status": "completed", "message": "Post erstellt!", "progress": 100,
|
||||
"result": {
|
||||
"post_id": str(result["post_id"]), "final_post": result["final_post"],
|
||||
"iterations": result["iterations"], "final_score": result["final_score"], "approved": result["approved"]
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
logger.exception(f"Post creation failed: {e}")
|
||||
progress_store[task_id] = {"status": "error", "message": str(e), "progress": 0}
|
||||
|
||||
background_tasks.add_task(run_create_post)
|
||||
return {"task_id": task_id}
|
||||
|
||||
|
||||
@admin_router.get("/api/posts")
|
||||
async def get_all_posts():
|
||||
"""Get all posts as JSON."""
|
||||
customers = await db.list_customers()
|
||||
all_posts = []
|
||||
for customer in customers:
|
||||
posts = await db.get_generated_posts(customer.id)
|
||||
for post in posts:
|
||||
all_posts.append({
|
||||
"id": str(post.id), "customer_name": customer.name, "topic_title": post.topic_title,
|
||||
"content": post.post_content, "iterations": post.iterations, "status": post.status,
|
||||
"created_at": post.created_at.isoformat() if post.created_at else None
|
||||
})
|
||||
return {"posts": all_posts, "total": len(all_posts)}
|
||||
|
||||
|
||||
class EmailRequest(BaseModel):
|
||||
recipient: str
|
||||
post_id: str
|
||||
|
||||
|
||||
@admin_router.get("/api/email/config")
|
||||
async def get_email_config(request: Request):
|
||||
"""Check if email is configured."""
|
||||
if not verify_auth(request):
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
return {"configured": email_service.is_configured(), "default_recipient": settings.email_default_recipient or ""}
|
||||
|
||||
|
||||
@admin_router.post("/api/email/send")
|
||||
async def send_post_email(request: Request, email_request: EmailRequest):
|
||||
"""Send a post via email."""
|
||||
if not verify_auth(request):
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
if not email_service.is_configured():
|
||||
raise HTTPException(status_code=400, detail="E-Mail ist nicht konfiguriert.")
|
||||
|
||||
try:
|
||||
post = await db.get_generated_post(UUID(email_request.post_id))
|
||||
if not post:
|
||||
raise HTTPException(status_code=404, detail="Post nicht gefunden")
|
||||
|
||||
customer = await db.get_customer(post.customer_id)
|
||||
score = None
|
||||
if post.critic_feedback and len(post.critic_feedback) > 0:
|
||||
score = post.critic_feedback[-1].get("overall_score")
|
||||
|
||||
success = email_service.send_post(
|
||||
recipient=email_request.recipient, post_content=post.post_content,
|
||||
topic_title=post.topic_title or "LinkedIn Post",
|
||||
customer_name=customer.name if customer else "Unbekannt", score=score
|
||||
)
|
||||
if success:
|
||||
return {"success": True, "message": f"E-Mail wurde an {email_request.recipient} gesendet"}
|
||||
else:
|
||||
raise HTTPException(status_code=500, detail="E-Mail konnte nicht gesendet werden.")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending email: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Fehler beim Senden: {str(e)}")
|
||||
39
src/web/app.py
Normal file
39
src/web/app.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""FastAPI web frontend for LinkedIn Post Creation System."""
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from src.config import settings
|
||||
from src.web.admin import admin_router
|
||||
|
||||
# Setup
|
||||
app = FastAPI(title="LinkedIn Post Creation System")
|
||||
|
||||
# Static files
|
||||
app.mount("/static", StaticFiles(directory=Path(__file__).parent / "static"), name="static")
|
||||
|
||||
# Include admin router (always available)
|
||||
app.include_router(admin_router)
|
||||
|
||||
# Include user router if enabled
|
||||
if settings.user_frontend_enabled:
|
||||
from src.web.user import user_router
|
||||
app.include_router(user_router)
|
||||
else:
|
||||
# Root redirect only when user frontend is disabled
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""Redirect root to admin frontend."""
|
||||
return RedirectResponse(url="/admin", status_code=302)
|
||||
|
||||
|
||||
def run_web():
|
||||
"""Run the web server."""
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_web()
|
||||
BIN
src/web/static/logo.png
Normal file
BIN
src/web/static/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
105
src/web/templates/admin/base.html
Normal file
105
src/web/templates/admin/base.html
Normal file
@@ -0,0 +1,105 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Admin - LinkedIn Posts{% endblock %}</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
'brand': {
|
||||
'bg': '#3d4848',
|
||||
'bg-light': '#4a5858',
|
||||
'bg-dark': '#2d3838',
|
||||
'highlight': '#ffc700',
|
||||
'highlight-dark': '#e6b300',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
body { background-color: #3d4848; }
|
||||
.nav-link.active { background-color: #ffc700; color: #2d3838; }
|
||||
.nav-link.active svg { stroke: #2d3838; }
|
||||
.post-content { white-space: pre-wrap; word-wrap: break-word; }
|
||||
.btn-primary { background-color: #ffc700; color: #2d3838; }
|
||||
.btn-primary:hover { background-color: #e6b300; }
|
||||
.sidebar-bg { background-color: #2d3838; }
|
||||
.card-bg { background-color: #4a5858; border-color: #5a6868; }
|
||||
.input-bg { background-color: #3d4848; border-color: #5a6868; }
|
||||
.input-bg:focus { border-color: #ffc700; outline: none; }
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-track { background: #3d4848; }
|
||||
::-webkit-scrollbar-thumb { background: #5a6868; border-radius: 4px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #6a7878; }
|
||||
</style>
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body class="text-gray-100 min-h-screen flex">
|
||||
<!-- Sidebar -->
|
||||
<aside class="w-64 sidebar-bg border-r border-gray-600 flex flex-col fixed h-full">
|
||||
<div class="p-4 border-b border-gray-600">
|
||||
<div class="flex items-center justify-center gap-3">
|
||||
<div>
|
||||
<img src="/static/logo.png" alt="Logo" class="h-15 w-auto">
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center mt-2">
|
||||
<span class="text-xs text-brand-highlight font-medium px-2 py-1 bg-brand-highlight/20 rounded">ADMIN</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="flex-1 p-4 space-y-2">
|
||||
<a href="/admin" 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 == 'home' %}active{% endif %}">
|
||||
<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="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/></svg>
|
||||
Dashboard
|
||||
</a>
|
||||
<a href="/admin/customers/new" 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 == 'new_customer' %}active{% endif %}">
|
||||
<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="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"/></svg>
|
||||
Neuer Kunde
|
||||
</a>
|
||||
<a href="/admin/research" 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 == 'research' %}active{% endif %}">
|
||||
<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
|
||||
Research Topics
|
||||
</a>
|
||||
<a href="/admin/create" 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 == 'create' %}active{% endif %}">
|
||||
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
||||
Post erstellen
|
||||
</a>
|
||||
<a href="/admin/posts" 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 == 'posts' %}active{% endif %}">
|
||||
<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="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/></svg>
|
||||
Alle Posts
|
||||
</a>
|
||||
<a href="/admin/scraped-posts" 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 == 'scraped_posts' %}active{% endif %}">
|
||||
<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="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"/></svg>
|
||||
Post-Typen
|
||||
</a>
|
||||
<a href="/admin/status" 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 == 'status' %}active{% endif %}">
|
||||
<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 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>
|
||||
Status
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div class="p-4 border-t border-gray-600">
|
||||
<a href="/admin/logout" class="flex items-center gap-2 text-gray-400 hover:text-gray-200 text-sm transition-colors">
|
||||
<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="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/></svg>
|
||||
Logout
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="flex-1 ml-64">
|
||||
<div class="p-8">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
539
src/web/templates/admin/create_post.html
Normal file
539
src/web/templates/admin/create_post.html
Normal file
@@ -0,0 +1,539 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Post erstellen - LinkedIn Posts{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Post erstellen</h1>
|
||||
<p class="text-gray-400">Generiere einen neuen LinkedIn Post mit AI</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<!-- Left: Form -->
|
||||
<div>
|
||||
<form id="createPostForm" class="card-bg rounded-xl border p-6 space-y-6">
|
||||
<!-- Customer Selection -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Kunde auswählen</label>
|
||||
<select name="customer_id" id="customerSelect" required class="w-full input-bg border rounded-lg px-4 py-3 text-white">
|
||||
<option value="">-- Kunde wählen --</option>
|
||||
{% for customer in customers %}
|
||||
<option value="{{ customer.id }}">{{ customer.name }} - {{ customer.company_name or 'Kein Unternehmen' }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Post Type Selection -->
|
||||
<div id="postTypeSelectionArea" class="hidden">
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Post-Typ auswählen (optional)</label>
|
||||
<div id="postTypeCards" class="flex flex-wrap gap-2 mb-2">
|
||||
<!-- Post type cards will be loaded here -->
|
||||
</div>
|
||||
<input type="hidden" id="selectedPostTypeId" value="">
|
||||
</div>
|
||||
|
||||
<!-- Topic Selection -->
|
||||
<div id="topicSelectionArea" class="hidden">
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Topic auswählen</label>
|
||||
<div id="topicsList" class="space-y-2 max-h-64 overflow-y-auto">
|
||||
<p class="text-gray-500">Lade Topics...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Topic -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">
|
||||
<span>Oder eigenes Topic eingeben</span>
|
||||
</label>
|
||||
<div class="space-y-3">
|
||||
<input type="text" id="customTopicTitle" placeholder="Topic Titel" class="w-full input-bg border rounded-lg px-4 py-2 text-white">
|
||||
<textarea id="customTopicFact" rows="3" placeholder="Fakt / Kernaussage zum Topic..." class="w-full input-bg border rounded-lg px-4 py-2 text-white"></textarea>
|
||||
<input type="text" id="customTopicSource" placeholder="Quelle (optional)" class="w-full input-bg border rounded-lg px-4 py-2 text-white">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress Area -->
|
||||
<div id="progressArea" class="hidden">
|
||||
<div class="bg-brand-bg rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span id="progressMessage" class="text-gray-300">Starte Post-Erstellung...</span>
|
||||
<span id="progressPercent" class="text-gray-400">0%</span>
|
||||
</div>
|
||||
<div class="w-full bg-brand-bg-dark rounded-full h-2">
|
||||
<div id="progressBar" class="bg-brand-highlight h-2 rounded-full transition-all duration-300" style="width: 0%"></div>
|
||||
</div>
|
||||
<div id="iterationInfo" class="mt-2 text-sm text-gray-400"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" id="submitBtn" class="w-full btn-primary font-medium py-3 rounded-lg transition-colors flex items-center justify-center gap-2">
|
||||
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
||||
Post generieren
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{% if not customers %}
|
||||
<div class="mt-4 bg-yellow-900/30 border border-yellow-600 rounded-lg p-4">
|
||||
<p class="text-yellow-300">Noch keine Kunden vorhanden. <a href="/admin/customers/new" class="underline">Erstelle zuerst einen Kunden</a>.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Right: Result -->
|
||||
<div>
|
||||
<div id="resultArea" class="card-bg rounded-xl border p-6">
|
||||
<h3 class="text-lg font-semibold text-white mb-4">Generierter Post</h3>
|
||||
|
||||
<!-- Live Versions Display -->
|
||||
<div id="liveVersions" class="hidden space-y-4 mb-6">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="text-sm text-gray-400">Live-Vorschau der Iterationen:</span>
|
||||
</div>
|
||||
<div id="versionsContainer" class="space-y-4"></div>
|
||||
</div>
|
||||
|
||||
<div id="postResult">
|
||||
<p class="text-gray-400">Wähle einen Kunden und ein Topic, dann klicke auf "Post generieren"...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const form = document.getElementById('createPostForm');
|
||||
const customerSelect = document.getElementById('customerSelect');
|
||||
const topicSelectionArea = document.getElementById('topicSelectionArea');
|
||||
const topicsList = document.getElementById('topicsList');
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
const progressArea = document.getElementById('progressArea');
|
||||
const progressBar = document.getElementById('progressBar');
|
||||
const progressMessage = document.getElementById('progressMessage');
|
||||
const progressPercent = document.getElementById('progressPercent');
|
||||
const iterationInfo = document.getElementById('iterationInfo');
|
||||
const postResult = document.getElementById('postResult');
|
||||
const liveVersions = document.getElementById('liveVersions');
|
||||
const versionsContainer = document.getElementById('versionsContainer');
|
||||
const postTypeSelectionArea = document.getElementById('postTypeSelectionArea');
|
||||
const postTypeCards = document.getElementById('postTypeCards');
|
||||
const selectedPostTypeIdInput = document.getElementById('selectedPostTypeId');
|
||||
|
||||
let selectedTopic = null;
|
||||
let currentVersionIndex = 0;
|
||||
let currentPostTypes = [];
|
||||
let currentTopics = [];
|
||||
|
||||
function renderVersions(versions, feedbackList) {
|
||||
if (!versions || versions.length === 0) {
|
||||
liveVersions.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
liveVersions.classList.remove('hidden');
|
||||
|
||||
// Build version tabs and content
|
||||
let html = `
|
||||
<div class="flex gap-2 mb-4 flex-wrap">
|
||||
${versions.map((_, i) => `
|
||||
<button onclick="showVersion(${i})" id="versionTab${i}"
|
||||
class="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors
|
||||
${i === currentVersionIndex ? 'bg-brand-highlight text-brand-bg-dark' : 'bg-brand-bg text-gray-300 hover:bg-brand-bg-light'}">
|
||||
V${i + 1}
|
||||
${feedbackList[i] ? `<span class="ml-1 text-xs opacity-75">(${feedbackList[i].overall_score || '?'})</span>` : ''}
|
||||
</button>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Show current version
|
||||
const currentVersion = versions[currentVersionIndex];
|
||||
const currentFeedback = feedbackList[currentVersionIndex];
|
||||
|
||||
html += `
|
||||
<div class="grid grid-cols-1 ${currentFeedback ? 'lg:grid-cols-2' : ''} gap-4">
|
||||
<div class="bg-brand-bg/50 rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-sm font-medium text-gray-300">Version ${currentVersionIndex + 1}</span>
|
||||
${currentFeedback ? `
|
||||
<span class="px-2 py-0.5 text-xs rounded ${currentFeedback.approved ? 'bg-green-600/30 text-green-300' : 'bg-yellow-600/30 text-yellow-300'}">
|
||||
${currentFeedback.approved ? 'Approved' : `Score: ${currentFeedback.overall_score}/100`}
|
||||
</span>
|
||||
` : '<span class="text-xs text-gray-500">Wird bewertet...</span>'}
|
||||
</div>
|
||||
<pre class="whitespace-pre-wrap text-gray-200 font-sans text-sm max-h-96 overflow-y-auto">${currentVersion}</pre>
|
||||
</div>
|
||||
${currentFeedback ? `
|
||||
<div class="bg-brand-bg/30 rounded-lg p-4 border border-brand-bg-light">
|
||||
<span class="text-sm font-medium text-gray-300 block mb-2">Kritik</span>
|
||||
<p class="text-sm text-gray-400 mb-3">${currentFeedback.feedback || 'Keine Kritik'}</p>
|
||||
${currentFeedback.improvements && currentFeedback.improvements.length > 0 ? `
|
||||
<div class="mt-2">
|
||||
<span class="text-xs font-medium text-gray-400">Verbesserungen:</span>
|
||||
<ul class="mt-1 space-y-1">
|
||||
${currentFeedback.improvements.map(imp => `
|
||||
<li class="text-xs text-gray-500 flex items-start gap-1">
|
||||
<span class="text-yellow-500">•</span> ${imp}
|
||||
</li>
|
||||
`).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
` : ''}
|
||||
${currentFeedback.scores ? `
|
||||
<div class="mt-3 pt-3 border-t border-brand-bg-light">
|
||||
<div class="grid grid-cols-3 gap-2 text-xs">
|
||||
<div class="text-center">
|
||||
<div class="text-gray-500">Authentizität</div>
|
||||
<div class="font-medium text-gray-300">${currentFeedback.scores.authenticity_and_style || '?'}/40</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-gray-500">Content</div>
|
||||
<div class="font-medium text-gray-300">${currentFeedback.scores.content_quality || '?'}/35</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-gray-500">Technik</div>
|
||||
<div class="font-medium text-gray-300">${currentFeedback.scores.technical_execution || '?'}/25</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
versionsContainer.innerHTML = html;
|
||||
}
|
||||
|
||||
function showVersion(index) {
|
||||
currentVersionIndex = index;
|
||||
// Get cached versions from progress store
|
||||
const cachedData = window.lastProgressData;
|
||||
if (cachedData) {
|
||||
renderVersions(cachedData.versions, cachedData.feedback_list);
|
||||
}
|
||||
}
|
||||
|
||||
// Load topics and post types when customer is selected
|
||||
customerSelect.addEventListener('change', async () => {
|
||||
const customerId = customerSelect.value;
|
||||
selectedPostTypeIdInput.value = '';
|
||||
|
||||
if (!customerId) {
|
||||
topicSelectionArea.classList.add('hidden');
|
||||
postTypeSelectionArea.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
topicSelectionArea.classList.remove('hidden');
|
||||
topicsList.innerHTML = '<p class="text-gray-500">Lade Topics...</p>';
|
||||
|
||||
// Load post types
|
||||
try {
|
||||
const ptResponse = await fetch(`/admin/api/customers/${customerId}/post-types`);
|
||||
const ptData = await ptResponse.json();
|
||||
|
||||
if (ptData.post_types && ptData.post_types.length > 0) {
|
||||
currentPostTypes = ptData.post_types;
|
||||
postTypeSelectionArea.classList.remove('hidden');
|
||||
|
||||
postTypeCards.innerHTML = `
|
||||
<button type="button" onclick="selectPostTypeForCreate('')" id="ptc_all"
|
||||
class="px-3 py-2 rounded-lg border text-sm transition-colors bg-brand-highlight/20 border-brand-highlight text-white">
|
||||
Alle Typen
|
||||
</button>
|
||||
` + ptData.post_types.map(pt => `
|
||||
<button type="button" onclick="selectPostTypeForCreate('${pt.id}')" id="ptc_${pt.id}"
|
||||
class="px-3 py-2 rounded-lg border text-sm transition-colors bg-brand-bg border-brand-bg-light hover:border-brand-highlight/50 text-white">
|
||||
${pt.name}
|
||||
${pt.has_analysis ? '<span class="ml-1 text-green-400 text-xs">*</span>' : ''}
|
||||
</button>
|
||||
`).join('');
|
||||
} else {
|
||||
postTypeSelectionArea.classList.add('hidden');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load post types:', error);
|
||||
postTypeSelectionArea.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Load topics
|
||||
try {
|
||||
const response = await fetch(`/admin/api/customers/${customerId}/topics`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.topics && data.topics.length > 0) {
|
||||
renderTopicsList(data);
|
||||
} else {
|
||||
// No topics available - show helpful message
|
||||
let message = '';
|
||||
if (data.used_count > 0) {
|
||||
message = `<div class="text-center py-4">
|
||||
<p class="text-gray-400 mb-2">Alle ${data.used_count} Topics wurden bereits verwendet.</p>
|
||||
<a href="/admin/research" class="text-brand-highlight hover:underline">Neue Topics recherchieren</a>
|
||||
<p class="text-gray-500 text-sm mt-2">oder gib unten ein eigenes Topic ein.</p>
|
||||
</div>`;
|
||||
} else {
|
||||
message = `<div class="text-center py-4">
|
||||
<p class="text-gray-400 mb-2">Keine Topics gefunden.</p>
|
||||
<a href="/admin/research" class="text-brand-highlight hover:underline">Recherche starten</a>
|
||||
<p class="text-gray-500 text-sm mt-2">oder gib unten ein eigenes Topic ein.</p>
|
||||
</div>`;
|
||||
}
|
||||
topicsList.innerHTML = message;
|
||||
}
|
||||
} catch (error) {
|
||||
topicsList.innerHTML = `<p class="text-red-400">Fehler beim Laden: ${error.message}</p>`;
|
||||
}
|
||||
});
|
||||
|
||||
// Clear selected topic when custom topic is entered
|
||||
['customTopicTitle', 'customTopicFact', 'customTopicSource'].forEach(id => {
|
||||
document.getElementById(id).addEventListener('input', () => {
|
||||
selectedTopic = null;
|
||||
document.querySelectorAll('input[name="topic"]').forEach(radio => radio.checked = false);
|
||||
});
|
||||
});
|
||||
|
||||
function selectPostTypeForCreate(typeId) {
|
||||
selectedPostTypeIdInput.value = typeId;
|
||||
|
||||
// Update card styles
|
||||
document.querySelectorAll('[id^="ptc_"]').forEach(card => {
|
||||
if (card.id === `ptc_${typeId}` || (typeId === '' && card.id === 'ptc_all')) {
|
||||
card.className = 'px-3 py-2 rounded-lg border text-sm transition-colors bg-brand-highlight/20 border-brand-highlight text-white';
|
||||
} else {
|
||||
card.className = 'px-3 py-2 rounded-lg border text-sm transition-colors bg-brand-bg border-brand-bg-light hover:border-brand-highlight/50 text-white';
|
||||
}
|
||||
});
|
||||
|
||||
// Optionally reload topics filtered by post type
|
||||
const customerId = customerSelect.value;
|
||||
if (customerId) {
|
||||
loadTopicsForPostType(customerId, typeId);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTopicsForPostType(customerId, postTypeId) {
|
||||
topicsList.innerHTML = '<p class="text-gray-500">Lade Topics...</p>';
|
||||
|
||||
try {
|
||||
let url = `/api/customers/${customerId}/topics`;
|
||||
if (postTypeId) {
|
||||
url += `?post_type_id=${postTypeId}`;
|
||||
}
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.topics && data.topics.length > 0) {
|
||||
renderTopicsList(data);
|
||||
} else {
|
||||
let message = '';
|
||||
if (data.used_count > 0) {
|
||||
message = `<div class="text-center py-4">
|
||||
<p class="text-gray-400 mb-2">Alle ${data.used_count} Topics wurden bereits verwendet.</p>
|
||||
<a href="/admin/research" class="text-brand-highlight hover:underline">Neue Topics recherchieren</a>
|
||||
<p class="text-gray-500 text-sm mt-2">oder gib unten ein eigenes Topic ein.</p>
|
||||
</div>`;
|
||||
} else {
|
||||
message = `<div class="text-center py-4">
|
||||
<p class="text-gray-400 mb-2">Keine Topics gefunden${postTypeId ? ' für diesen Post-Typ' : ''}.</p>
|
||||
<a href="/admin/research" class="text-brand-highlight hover:underline">Recherche starten</a>
|
||||
<p class="text-gray-500 text-sm mt-2">oder gib unten ein eigenes Topic ein.</p>
|
||||
</div>`;
|
||||
}
|
||||
topicsList.innerHTML = message;
|
||||
}
|
||||
} catch (error) {
|
||||
topicsList.innerHTML = `<p class="text-red-400">Fehler beim Laden: ${error.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderTopicsList(data) {
|
||||
// Store topics in global array for safe access
|
||||
currentTopics = data.topics;
|
||||
|
||||
// Reset selected topic when list is re-rendered
|
||||
selectedTopic = null;
|
||||
|
||||
let statsHtml = '';
|
||||
if (data.used_count > 0) {
|
||||
statsHtml = `<p class="text-xs text-gray-500 mb-3">${data.available_count} verfügbar · ${data.used_count} bereits verwendet</p>`;
|
||||
}
|
||||
|
||||
topicsList.innerHTML = statsHtml + data.topics.map((topic, i) => `
|
||||
<label class="flex items-start gap-3 p-3 bg-brand-bg/50 rounded-lg cursor-pointer hover:bg-brand-bg transition-colors border border-transparent hover:border-brand-highlight/30">
|
||||
<input type="radio" name="topic" value="${i}" class="mt-1 text-brand-highlight" data-topic-index="${i}">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<span class="inline-block px-2 py-0.5 text-xs font-medium bg-brand-highlight/20 text-brand-highlight rounded">${escapeHtml(topic.category || 'Topic')}</span>
|
||||
${topic.target_post_type_id ? `<span class="text-xs text-gray-500">Typ-spezifisch</span>` : ''}
|
||||
${topic.source ? `<span class="text-xs text-gray-500">🔗 ${escapeHtml(topic.source.substring(0, 30))}${topic.source.length > 30 ? '...' : ''}</span>` : ''}
|
||||
</div>
|
||||
<p class="font-medium text-white">${escapeHtml(topic.title)}</p>
|
||||
${topic.angle ? `<p class="text-xs text-brand-highlight/80 mt-1">→ ${escapeHtml(topic.angle)}</p>` : ''}
|
||||
${topic.hook_idea ? `<p class="text-sm text-gray-300 mt-2 italic border-l-2 border-brand-highlight/30 pl-2">"${escapeHtml(topic.hook_idea.substring(0, 120))}${topic.hook_idea.length > 120 ? '...' : ''}"</p>` : ''}
|
||||
${topic.key_facts && topic.key_facts.length > 0 ? `
|
||||
<div class="mt-2 flex flex-wrap gap-1">
|
||||
${topic.key_facts.slice(0, 2).map(f => `<span class="text-xs bg-brand-bg-dark px-2 py-0.5 rounded text-gray-400">📊 ${escapeHtml(f.substring(0, 40))}${f.length > 40 ? '...' : ''}</span>`).join('')}
|
||||
</div>
|
||||
` : (topic.fact ? `<p class="text-sm text-gray-400 mt-1">${escapeHtml(topic.fact.substring(0, 100))}...</p>` : '')}
|
||||
${topic.why_this_person ? `<p class="text-xs text-gray-500 mt-2">💡 ${escapeHtml(topic.why_this_person.substring(0, 80))}${topic.why_this_person.length > 80 ? '...' : ''}</p>` : ''}
|
||||
</div>
|
||||
</label>
|
||||
`).join('');
|
||||
|
||||
// Add event listeners to radio buttons
|
||||
document.querySelectorAll('input[name="topic"]').forEach(radio => {
|
||||
radio.addEventListener('change', () => {
|
||||
const index = parseInt(radio.dataset.topicIndex, 10);
|
||||
selectedTopic = currentTopics[index];
|
||||
// Clear custom topic fields
|
||||
document.getElementById('customTopicTitle').value = '';
|
||||
document.getElementById('customTopicFact').value = '';
|
||||
document.getElementById('customTopicSource').value = '';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Helper function to escape HTML special characters
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const customerId = customerSelect.value;
|
||||
if (!customerId) {
|
||||
alert('Bitte wähle einen Kunden aus.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get topic (either selected or custom)
|
||||
let topic;
|
||||
const customTitle = document.getElementById('customTopicTitle').value.trim();
|
||||
const customFact = document.getElementById('customTopicFact').value.trim();
|
||||
|
||||
if (customTitle && customFact) {
|
||||
topic = {
|
||||
title: customTitle,
|
||||
fact: customFact,
|
||||
source: document.getElementById('customTopicSource').value.trim() || null,
|
||||
category: 'Custom'
|
||||
};
|
||||
} else if (selectedTopic) {
|
||||
topic = selectedTopic;
|
||||
} else {
|
||||
alert('Bitte wähle ein Topic aus oder gib ein eigenes ein.');
|
||||
return;
|
||||
}
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<svg class="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg> Generiert...';
|
||||
progressArea.classList.remove('hidden');
|
||||
postResult.innerHTML = '<p class="text-gray-400">Post wird generiert...</p>';
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('customer_id', customerId);
|
||||
formData.append('topic_json', JSON.stringify(topic));
|
||||
if (selectedPostTypeIdInput.value) {
|
||||
formData.append('post_type_id', selectedPostTypeIdInput.value);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/admin/api/posts', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
const taskId = data.task_id;
|
||||
currentVersionIndex = 0;
|
||||
window.lastProgressData = null;
|
||||
|
||||
const pollInterval = setInterval(async () => {
|
||||
const statusResponse = await fetch(`/admin/api/tasks/${taskId}`);
|
||||
const status = await statusResponse.json();
|
||||
|
||||
progressBar.style.width = `${status.progress}%`;
|
||||
progressPercent.textContent = `${status.progress}%`;
|
||||
progressMessage.textContent = status.message;
|
||||
|
||||
if (status.iteration !== undefined) {
|
||||
iterationInfo.textContent = `Iteration ${status.iteration}/${status.max_iterations}`;
|
||||
}
|
||||
|
||||
// Update live versions display
|
||||
if (status.versions && status.versions.length > 0) {
|
||||
window.lastProgressData = status;
|
||||
// Auto-select latest version
|
||||
if (status.versions.length > currentVersionIndex + 1) {
|
||||
currentVersionIndex = status.versions.length - 1;
|
||||
}
|
||||
renderVersions(status.versions, status.feedback_list || []);
|
||||
postResult.innerHTML = '<p class="text-gray-400">Siehe Live-Vorschau oben...</p>';
|
||||
}
|
||||
|
||||
if (status.status === 'completed') {
|
||||
clearInterval(pollInterval);
|
||||
progressArea.classList.add('hidden');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = '<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg> Post generieren';
|
||||
|
||||
// Keep live versions visible but update header
|
||||
const result = status.result;
|
||||
|
||||
postResult.innerHTML = `
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-2 text-sm flex-wrap">
|
||||
<span class="px-2 py-1 rounded ${result.approved ? 'bg-green-600/30 text-green-300' : 'bg-yellow-600/30 text-yellow-300'}">
|
||||
${result.approved ? 'Approved' : 'Review needed'}
|
||||
</span>
|
||||
<span class="text-gray-400">Score: ${result.final_score}/100</span>
|
||||
<span class="text-gray-400">Iterations: ${result.iterations}</span>
|
||||
</div>
|
||||
<div class="text-sm text-gray-400 mb-2">Finaler Post:</div>
|
||||
<div class="bg-brand-bg/50 rounded-lg p-4">
|
||||
<pre class="whitespace-pre-wrap text-gray-200 font-sans">${result.final_post}</pre>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="copyPost()" class="px-4 py-2 bg-brand-bg hover:bg-brand-bg-light rounded-lg text-sm text-white transition-colors">
|
||||
In Zwischenablage kopieren
|
||||
</button>
|
||||
<button onclick="toggleVersions()" class="px-4 py-2 bg-brand-bg hover:bg-brand-bg-light rounded-lg text-sm text-white transition-colors">
|
||||
Versionen ${liveVersions.classList.contains('hidden') ? 'anzeigen' : 'ausblenden'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else if (status.status === 'error') {
|
||||
clearInterval(pollInterval);
|
||||
progressArea.classList.add('hidden');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = '<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg> Post generieren';
|
||||
postResult.innerHTML = `<p class="text-red-400">Fehler: ${status.message}</p>`;
|
||||
}
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
progressArea.classList.add('hidden');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = '<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg> Post generieren';
|
||||
postResult.innerHTML = `<p class="text-red-400">Fehler: ${error.message}</p>`;
|
||||
}
|
||||
});
|
||||
|
||||
function copyPost() {
|
||||
const postText = document.querySelector('#postResult pre').textContent;
|
||||
navigator.clipboard.writeText(postText).then(() => {
|
||||
alert('Post in Zwischenablage kopiert!');
|
||||
});
|
||||
}
|
||||
|
||||
function toggleVersions() {
|
||||
liveVersions.classList.toggle('hidden');
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
97
src/web/templates/admin/dashboard.html
Normal file
97
src/web/templates/admin/dashboard.html
Normal file
@@ -0,0 +1,97 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Dashboard - LinkedIn Posts{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Dashboard</h1>
|
||||
<p class="text-gray-400">Willkommen zum LinkedIn Post Creation System</p>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="bg-red-900/50 border border-red-500 text-red-200 px-4 py-3 rounded-lg mb-6">
|
||||
<strong>Error:</strong> {{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<div class="card-bg rounded-xl border p-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 bg-brand-highlight/20 rounded-lg flex items-center justify-center">
|
||||
<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="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-gray-400 text-sm">Kunden</p>
|
||||
<p class="text-2xl font-bold text-white">{{ customers_count or 0 }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-bg rounded-xl border p-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 bg-green-600/20 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-green-400" 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"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-gray-400 text-sm">Generierte Posts</p>
|
||||
<p class="text-2xl font-bold text-white">{{ total_posts or 0 }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-bg rounded-xl border p-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 bg-brand-highlight/20 rounded-lg flex items-center justify-center">
|
||||
<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="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-gray-400 text-sm">AI Agents</p>
|
||||
<p class="text-2xl font-bold text-white">5</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="card-bg rounded-xl border p-6">
|
||||
<h2 class="text-xl font-semibold text-white mb-4">Schnellaktionen</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<a href="/admin/customers/new" class="flex items-center gap-3 p-4 bg-brand-bg rounded-lg hover:bg-brand-bg-light transition-colors">
|
||||
<div class="w-10 h-10 btn-primary rounded-lg flex items-center justify-center">
|
||||
<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="M12 6v6m0 0v6m0-6h6m-6 0H6"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-white">Neuer Kunde</p>
|
||||
<p class="text-sm text-gray-400">Setup starten</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="/admin/research" class="flex items-center gap-3 p-4 bg-brand-bg rounded-lg hover:bg-brand-bg-light transition-colors">
|
||||
<div class="w-10 h-10 btn-primary rounded-lg flex items-center justify-center">
|
||||
<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-white">Research</p>
|
||||
<p class="text-sm text-gray-400">Topics finden</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="/admin/create" class="flex items-center gap-3 p-4 bg-brand-bg rounded-lg hover:bg-brand-bg-light transition-colors">
|
||||
<div class="w-10 h-10 btn-primary rounded-lg flex items-center justify-center">
|
||||
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-white">Post erstellen</p>
|
||||
<p class="text-sm text-gray-400">Content generieren</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="/admin/posts" class="flex items-center gap-3 p-4 bg-brand-bg rounded-lg hover:bg-brand-bg-light transition-colors">
|
||||
<div class="w-10 h-10 btn-primary rounded-lg flex items-center justify-center">
|
||||
<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="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-white">Alle Posts</p>
|
||||
<p class="text-sm text-gray-400">Übersicht anzeigen</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
72
src/web/templates/admin/login.html
Normal file
72
src/web/templates/admin/login.html
Normal file
@@ -0,0 +1,72 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Login - LinkedIn Posts</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
'brand': {
|
||||
'bg': '#3d4848',
|
||||
'bg-light': '#4a5858',
|
||||
'bg-dark': '#2d3838',
|
||||
'highlight': '#ffc700',
|
||||
'highlight-dark': '#e6b300',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
body { background-color: #3d4848; }
|
||||
.btn-primary { background-color: #ffc700; color: #2d3838; }
|
||||
.btn-primary:hover { background-color: #e6b300; }
|
||||
.card-bg { background-color: #4a5858; border-color: #5a6868; }
|
||||
.input-bg { background-color: #3d4848; border-color: #5a6868; }
|
||||
.input-bg:focus { border-color: #ffc700; outline: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="text-gray-100 min-h-screen flex items-center justify-center">
|
||||
<div class="w-full max-w-md">
|
||||
<div class="card-bg rounded-xl border p-8">
|
||||
<div class="text-center mb-8">
|
||||
<img src="/static/logo.png" alt="Logo" class="h-16 w-auto mx-auto mb-4">
|
||||
<h1 class="text-2xl font-bold text-white mb-2">LinkedIn Posts</h1>
|
||||
<p class="text-gray-400">Admin Panel</p>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="bg-red-900/50 border border-red-500 text-red-200 px-4 py-3 rounded-lg mb-6">
|
||||
Falsches Passwort. Bitte versuche es erneut.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="POST" action="/admin/login" class="space-y-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Passwort</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
required
|
||||
autofocus
|
||||
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
|
||||
placeholder="Passwort eingeben..."
|
||||
>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full btn-primary font-medium py-3 rounded-lg transition-colors"
|
||||
>
|
||||
Anmelden
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
274
src/web/templates/admin/new_customer.html
Normal file
274
src/web/templates/admin/new_customer.html
Normal file
@@ -0,0 +1,274 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Neuer Kunde - LinkedIn Posts{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Neuer Kunde</h1>
|
||||
<p class="text-gray-400">Richte einen neuen Kunden ein und starte das initiale Setup</p>
|
||||
</div>
|
||||
|
||||
<div class="max-w-2xl">
|
||||
<form id="customerForm" class="card-bg rounded-xl border p-6 space-y-6">
|
||||
<!-- Basic Info -->
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-white mb-4">Basis-Informationen</h3>
|
||||
<div class="grid gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Name *</label>
|
||||
<input type="text" name="name" required class="w-full input-bg border rounded-lg px-4 py-2 text-white">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">LinkedIn URL *</label>
|
||||
<input type="url" name="linkedin_url" required placeholder="https://www.linkedin.com/in/username" class="w-full input-bg border rounded-lg px-4 py-2 text-white">
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Firma</label>
|
||||
<input type="text" name="company_name" class="w-full input-bg border rounded-lg px-4 py-2 text-white">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">E-Mail</label>
|
||||
<input type="email" name="email" class="w-full input-bg border rounded-lg px-4 py-2 text-white">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Persona -->
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-white mb-4">Persona & Stil</h3>
|
||||
<div class="grid gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Persona</label>
|
||||
<textarea name="persona" rows="3" placeholder="Beschreibe die Expertise, Positionierung und den Charakter der Person..." class="w-full input-bg border rounded-lg px-4 py-2 text-white"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Ansprache</label>
|
||||
<input type="text" name="form_of_address" placeholder="z.B. Duzen (Du/Euch) oder Siezen (Sie)" class="w-full input-bg border rounded-lg px-4 py-2 text-white">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Style Guide</label>
|
||||
<textarea name="style_guide" rows="3" placeholder="Beschreibe den Schreibstil, Tonalität und Richtlinien..." class="w-full input-bg border rounded-lg px-4 py-2 text-white"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Post Types -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-white">Post-Typen</h3>
|
||||
<button type="button" id="addPostTypeBtn" class="text-sm text-brand-highlight hover:underline">+ Post-Typ hinzufügen</button>
|
||||
</div>
|
||||
<p class="text-sm text-gray-400 mb-4">Definiere verschiedene Arten von Posts (z.B. "Thought Leader", "Case Study", "How-To"). Diese werden zur Kategorisierung und typ-spezifischen Analyse verwendet.</p>
|
||||
|
||||
<div id="postTypesContainer" class="space-y-4">
|
||||
<!-- Post type entries will be added here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress Area -->
|
||||
<div id="progressArea" class="hidden">
|
||||
<div class="bg-brand-bg rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span id="progressMessage" class="text-gray-300">Starte Setup...</span>
|
||||
<span id="progressPercent" class="text-gray-400">0%</span>
|
||||
</div>
|
||||
<div class="w-full bg-brand-bg-dark rounded-full h-2">
|
||||
<div id="progressBar" class="bg-brand-highlight h-2 rounded-full transition-all duration-300" style="width: 0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Result Area -->
|
||||
<div id="resultArea" class="hidden">
|
||||
<div id="successResult" class="hidden bg-green-900/30 border border-green-500 rounded-lg p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-6 h-6 text-green-500" 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>
|
||||
<span class="text-green-300">Setup erfolgreich abgeschlossen!</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="errorResult" class="hidden bg-red-900/30 border border-red-500 rounded-lg p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-6 h-6 text-red-500" 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>
|
||||
<span id="errorMessage" class="text-red-300">Fehler beim Setup</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<div class="flex gap-4">
|
||||
<button type="submit" id="submitBtn" class="flex-1 btn-primary font-medium py-3 rounded-lg transition-colors">
|
||||
Setup starten
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const form = document.getElementById('customerForm');
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
const progressArea = document.getElementById('progressArea');
|
||||
const resultArea = document.getElementById('resultArea');
|
||||
const progressBar = document.getElementById('progressBar');
|
||||
const progressMessage = document.getElementById('progressMessage');
|
||||
const progressPercent = document.getElementById('progressPercent');
|
||||
const postTypesContainer = document.getElementById('postTypesContainer');
|
||||
const addPostTypeBtn = document.getElementById('addPostTypeBtn');
|
||||
|
||||
let postTypeIndex = 0;
|
||||
|
||||
function createPostTypeEntry() {
|
||||
const index = postTypeIndex++;
|
||||
const entry = document.createElement('div');
|
||||
entry.className = 'bg-brand-bg rounded-lg p-4 border border-brand-bg-light';
|
||||
entry.id = `postType_${index}`;
|
||||
entry.innerHTML = `
|
||||
<div class="flex justify-between items-start mb-3">
|
||||
<span class="text-sm font-medium text-gray-300">Post-Typ ${index + 1}</span>
|
||||
<button type="button" onclick="removePostType(${index})" class="text-red-400 hover:text-red-300 text-sm">Entfernen</button>
|
||||
</div>
|
||||
<div class="grid gap-3">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-400 mb-1">Name *</label>
|
||||
<input type="text" data-pt-field="name" data-pt-index="${index}" required placeholder="z.B. Thought Leader" class="w-full input-bg border rounded-lg px-3 py-2 text-white text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-400 mb-1">Beschreibung</label>
|
||||
<input type="text" data-pt-field="description" data-pt-index="${index}" placeholder="Kurze Beschreibung" class="w-full input-bg border rounded-lg px-3 py-2 text-white text-sm">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-400 mb-1">Identifizierende Hashtags (kommagetrennt)</label>
|
||||
<input type="text" data-pt-field="hashtags" data-pt-index="${index}" placeholder="#ThoughtLeader, #Insight, #Leadership" class="w-full input-bg border rounded-lg px-3 py-2 text-white text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-400 mb-1">Keywords (kommagetrennt)</label>
|
||||
<input type="text" data-pt-field="keywords" data-pt-index="${index}" placeholder="Erfahrung, Learnings, Meinung" class="w-full input-bg border rounded-lg px-3 py-2 text-white text-sm">
|
||||
</div>
|
||||
<details class="mt-2">
|
||||
<summary class="text-xs text-gray-400 cursor-pointer hover:text-gray-300">Erweiterte Eigenschaften</summary>
|
||||
<div class="mt-3 grid gap-3">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-400 mb-1">Zweck</label>
|
||||
<input type="text" data-pt-field="purpose" data-pt-index="${index}" placeholder="z.B. Expertise zeigen, Meinungsführerschaft etablieren" class="w-full input-bg border rounded-lg px-3 py-2 text-white text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-400 mb-1">Typische Tonalität</label>
|
||||
<input type="text" data-pt-field="tone" data-pt-index="${index}" placeholder="z.B. reflektiert, provokativ, inspirierend" class="w-full input-bg border rounded-lg px-3 py-2 text-white text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-400 mb-1">Zielgruppe</label>
|
||||
<input type="text" data-pt-field="target_audience" data-pt-index="${index}" placeholder="z.B. Führungskräfte, Entscheider" class="w-full input-bg border rounded-lg px-3 py-2 text-white text-sm">
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
`;
|
||||
postTypesContainer.appendChild(entry);
|
||||
}
|
||||
|
||||
function removePostType(index) {
|
||||
const entry = document.getElementById(`postType_${index}`);
|
||||
if (entry) {
|
||||
entry.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function collectPostTypes() {
|
||||
const postTypes = [];
|
||||
const entries = postTypesContainer.querySelectorAll('[id^="postType_"]');
|
||||
|
||||
entries.forEach(entry => {
|
||||
const index = entry.id.split('_')[1];
|
||||
const name = entry.querySelector(`[data-pt-field="name"][data-pt-index="${index}"]`)?.value?.trim();
|
||||
|
||||
if (name) {
|
||||
const hashtagsRaw = entry.querySelector(`[data-pt-field="hashtags"][data-pt-index="${index}"]`)?.value || '';
|
||||
const keywordsRaw = entry.querySelector(`[data-pt-field="keywords"][data-pt-index="${index}"]`)?.value || '';
|
||||
|
||||
postTypes.push({
|
||||
name: name,
|
||||
description: entry.querySelector(`[data-pt-field="description"][data-pt-index="${index}"]`)?.value?.trim() || null,
|
||||
identifying_hashtags: hashtagsRaw.split(',').map(h => h.trim()).filter(h => h),
|
||||
identifying_keywords: keywordsRaw.split(',').map(k => k.trim()).filter(k => k),
|
||||
semantic_properties: {
|
||||
purpose: entry.querySelector(`[data-pt-field="purpose"][data-pt-index="${index}"]`)?.value?.trim() || null,
|
||||
typical_tone: entry.querySelector(`[data-pt-field="tone"][data-pt-index="${index}"]`)?.value?.trim() || null,
|
||||
target_audience: entry.querySelector(`[data-pt-field="target_audience"][data-pt-index="${index}"]`)?.value?.trim() || null
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return postTypes;
|
||||
}
|
||||
|
||||
addPostTypeBtn.addEventListener('click', createPostTypeEntry);
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Wird gestartet...';
|
||||
progressArea.classList.remove('hidden');
|
||||
resultArea.classList.add('hidden');
|
||||
|
||||
const formData = new FormData(form);
|
||||
|
||||
// Add post types as JSON
|
||||
const postTypes = collectPostTypes();
|
||||
if (postTypes.length > 0) {
|
||||
formData.append('post_types_json', JSON.stringify(postTypes));
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/admin/api/customers', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
// Poll for progress
|
||||
const taskId = data.task_id;
|
||||
const pollInterval = setInterval(async () => {
|
||||
const statusResponse = await fetch(`/admin/api/tasks/${taskId}`);
|
||||
const status = await statusResponse.json();
|
||||
|
||||
progressBar.style.width = `${status.progress}%`;
|
||||
progressPercent.textContent = `${status.progress}%`;
|
||||
progressMessage.textContent = status.message;
|
||||
|
||||
if (status.status === 'completed') {
|
||||
clearInterval(pollInterval);
|
||||
progressArea.classList.add('hidden');
|
||||
resultArea.classList.remove('hidden');
|
||||
document.getElementById('successResult').classList.remove('hidden');
|
||||
submitBtn.textContent = 'Setup starten';
|
||||
submitBtn.disabled = false;
|
||||
form.reset();
|
||||
postTypesContainer.innerHTML = '';
|
||||
postTypeIndex = 0;
|
||||
} else if (status.status === 'error') {
|
||||
clearInterval(pollInterval);
|
||||
progressArea.classList.add('hidden');
|
||||
resultArea.classList.remove('hidden');
|
||||
document.getElementById('errorResult').classList.remove('hidden');
|
||||
document.getElementById('errorMessage').textContent = status.message;
|
||||
submitBtn.textContent = 'Setup starten';
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
progressArea.classList.add('hidden');
|
||||
resultArea.classList.remove('hidden');
|
||||
document.getElementById('errorResult').classList.remove('hidden');
|
||||
document.getElementById('errorMessage').textContent = error.message;
|
||||
submitBtn.textContent = 'Setup starten';
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
1481
src/web/templates/admin/post_detail.html
Normal file
1481
src/web/templates/admin/post_detail.html
Normal file
File diff suppressed because it is too large
Load Diff
152
src/web/templates/admin/posts.html
Normal file
152
src/web/templates/admin/posts.html
Normal file
@@ -0,0 +1,152 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Alle Posts - LinkedIn Posts{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<style>
|
||||
.post-card {
|
||||
background: linear-gradient(135deg, rgba(61, 72, 72, 0.3) 0%, rgba(45, 56, 56, 0.4) 100%);
|
||||
border: 1px solid rgba(61, 72, 72, 0.6);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.post-card:hover {
|
||||
border-color: rgba(255, 199, 0, 0.3);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.customer-header {
|
||||
background: linear-gradient(90deg, rgba(255, 199, 0, 0.1) 0%, transparent 100%);
|
||||
}
|
||||
.score-ring {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.score-high { background: rgba(34, 197, 94, 0.2); border: 2px solid rgba(34, 197, 94, 0.5); color: #86efac; }
|
||||
.score-medium { background: rgba(234, 179, 8, 0.2); border: 2px solid rgba(234, 179, 8, 0.5); color: #fde047; }
|
||||
.score-low { background: rgba(239, 68, 68, 0.2); border: 2px solid rgba(239, 68, 68, 0.5); color: #fca5a5; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Alle Posts</h1>
|
||||
<p class="text-gray-400">{{ total_posts }} generierte Posts</p>
|
||||
</div>
|
||||
<a href="/admin/create" class="px-4 py-2.5 btn-primary rounded-lg font-medium flex items-center gap-2">
|
||||
<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="M12 6v6m0 0v6m0-6h6m-6 0H6"/></svg>
|
||||
Neuer Post
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="bg-red-900/50 border border-red-500 text-red-200 px-4 py-3 rounded-lg mb-6">
|
||||
<strong>Error:</strong> {{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if customers_with_posts %}
|
||||
<div class="space-y-8">
|
||||
{% for item in customers_with_posts %}
|
||||
{% if item.posts %}
|
||||
<div class="card-bg rounded-xl border overflow-hidden">
|
||||
<!-- Customer Header -->
|
||||
<div class="customer-header px-6 py-4 border-b border-brand-bg-light">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 rounded-xl flex items-center justify-center shadow-lg overflow-hidden {{ 'bg-brand-highlight' if not item.profile_picture else '' }}">
|
||||
{% if item.profile_picture %}
|
||||
<img src="{{ item.profile_picture }}" alt="{{ item.customer.name }}" class="w-full h-full object-cover" loading="lazy" referrerpolicy="no-referrer">
|
||||
{% else %}
|
||||
<span class="text-brand-bg-dark font-bold text-lg">{{ item.customer.name[0] | upper }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-white text-lg">{{ item.customer.name }}</h3>
|
||||
<p class="text-sm text-gray-400">{{ item.customer.company_name or 'Kein Unternehmen' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="px-4 py-1.5 bg-brand-bg rounded-full text-sm text-gray-300 font-medium">
|
||||
{{ item.post_count }} Post{{ 's' if item.post_count != 1 else '' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Posts Grid -->
|
||||
<div class="p-4">
|
||||
<div class="grid gap-3">
|
||||
{% for post in item.posts %}
|
||||
<a href="/admin/posts/{{ post.id }}" class="post-card rounded-xl p-4 block group">
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Score Circle -->
|
||||
{% if post.critic_feedback and post.critic_feedback | length > 0 %}
|
||||
{% set score = post.critic_feedback[-1].overall_score %}
|
||||
<div class="score-ring flex-shrink-0 {{ 'score-high' if score >= 85 else 'score-medium' if score >= 70 else 'score-low' }}">
|
||||
{{ score }}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="score-ring flex-shrink-0 bg-brand-bg-dark border-2 border-brand-bg-light text-gray-500">
|
||||
—
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<h4 class="font-medium text-white group-hover:text-brand-highlight transition-colors truncate">
|
||||
{{ post.topic_title or 'Untitled' }}
|
||||
</h4>
|
||||
<span class="flex-shrink-0 px-2 py-0.5 text-xs rounded font-medium {{ 'bg-green-600/20 text-green-400 border border-green-600/30' if post.status == 'approved' else 'bg-yellow-600/20 text-yellow-400 border border-yellow-600/30' }}">
|
||||
{{ post.status | capitalize }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4 mt-1.5 text-sm text-gray-500">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
|
||||
{{ post.created_at.strftime('%d.%m.%Y') if post.created_at else 'N/A' }}
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<svg class="w-3.5 h-3.5" 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>
|
||||
{{ post.created_at.strftime('%H:%M') if post.created_at else '' }}
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
|
||||
{{ post.iterations }} Iteration{{ 's' if post.iterations != 1 else '' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Arrow -->
|
||||
<svg class="w-5 h-5 text-gray-600 group-hover:text-brand-highlight transition-colors flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card-bg rounded-xl border p-12 text-center">
|
||||
<div class="w-20 h-20 bg-brand-bg rounded-2xl flex items-center justify-center mx-auto mb-6">
|
||||
<svg class="w-10 h-10 text-gray-600" 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"/></svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-white mb-2">Noch keine Posts</h3>
|
||||
<p class="text-gray-400 mb-6 max-w-md mx-auto">Erstelle deinen ersten LinkedIn Post mit KI-Unterstützung.</p>
|
||||
<a href="/admin/create" class="inline-flex items-center gap-2 px-6 py-3 btn-primary font-medium rounded-lg transition-colors">
|
||||
<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="M12 6v6m0 0v6m0-6h6m-6 0H6"/></svg>
|
||||
Post erstellen
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
215
src/web/templates/admin/research.html
Normal file
215
src/web/templates/admin/research.html
Normal file
@@ -0,0 +1,215 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Research Topics - LinkedIn Posts{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Research Topics</h1>
|
||||
<p class="text-gray-400">Recherchiere neue Content-Themen mit Perplexity AI</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<!-- Left: Form -->
|
||||
<div>
|
||||
<form id="researchForm" class="card-bg rounded-xl border p-6">
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Kunde auswählen</label>
|
||||
<select name="customer_id" id="customerSelect" required class="w-full input-bg border rounded-lg px-4 py-3 text-white">
|
||||
<option value="">-- Kunde wählen --</option>
|
||||
{% for customer in customers %}
|
||||
<option value="{{ customer.id }}">{{ customer.name }} - {{ customer.company_name or 'Kein Unternehmen' }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Post Type Selection -->
|
||||
<div id="postTypeArea" class="mb-6 hidden">
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Post-Typ (optional)</label>
|
||||
<div id="postTypeCards" class="grid grid-cols-2 gap-2 mb-2">
|
||||
<!-- Post type cards will be loaded here -->
|
||||
</div>
|
||||
<p class="text-xs text-gray-500">Wähle einen Post-Typ für gezielte Recherche oder lasse leer für allgemeine Recherche.</p>
|
||||
<input type="hidden" name="post_type_id" id="selectedPostTypeId" value="">
|
||||
</div>
|
||||
|
||||
<!-- Progress Area -->
|
||||
<div id="progressArea" class="hidden mb-6">
|
||||
<div class="bg-brand-bg rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span id="progressMessage" class="text-gray-300">Starte Recherche...</span>
|
||||
<span id="progressPercent" class="text-gray-400">0%</span>
|
||||
</div>
|
||||
<div class="w-full bg-brand-bg-dark rounded-full h-2">
|
||||
<div id="progressBar" class="bg-brand-highlight h-2 rounded-full transition-all duration-300" style="width: 0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" id="submitBtn" class="w-full btn-primary font-medium py-3 rounded-lg transition-colors flex items-center justify-center gap-2">
|
||||
<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
|
||||
Research starten
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{% if not customers %}
|
||||
<div class="mt-4 bg-yellow-900/30 border border-yellow-600 rounded-lg p-4">
|
||||
<p class="text-yellow-300">Noch keine Kunden vorhanden. <a href="/admin/customers/new" class="underline">Erstelle zuerst einen Kunden</a>.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Right: Results -->
|
||||
<div>
|
||||
<div id="resultsArea" class="card-bg rounded-xl border p-6">
|
||||
<h3 class="text-lg font-semibold text-white mb-4">Gefundene Topics</h3>
|
||||
<div id="topicsList" class="space-y-4">
|
||||
<p class="text-gray-400">Starte eine Recherche um Topics zu finden...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const form = document.getElementById('researchForm');
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
const progressArea = document.getElementById('progressArea');
|
||||
const progressBar = document.getElementById('progressBar');
|
||||
const progressMessage = document.getElementById('progressMessage');
|
||||
const progressPercent = document.getElementById('progressPercent');
|
||||
const topicsList = document.getElementById('topicsList');
|
||||
const customerSelect = document.getElementById('customerSelect');
|
||||
const postTypeArea = document.getElementById('postTypeArea');
|
||||
const postTypeCards = document.getElementById('postTypeCards');
|
||||
const selectedPostTypeId = document.getElementById('selectedPostTypeId');
|
||||
|
||||
let currentPostTypes = [];
|
||||
|
||||
// Load post types when customer is selected
|
||||
customerSelect.addEventListener('change', async () => {
|
||||
const customerId = customerSelect.value;
|
||||
selectedPostTypeId.value = '';
|
||||
|
||||
if (!customerId) {
|
||||
postTypeArea.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/admin/api/customers/${customerId}/post-types`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.post_types && data.post_types.length > 0) {
|
||||
currentPostTypes = data.post_types;
|
||||
postTypeArea.classList.remove('hidden');
|
||||
|
||||
postTypeCards.innerHTML = `
|
||||
<button type="button" onclick="selectPostType('')" id="pt_all"
|
||||
class="p-3 rounded-lg border text-left transition-colors bg-brand-highlight/20 border-brand-highlight text-white">
|
||||
<div class="font-medium text-sm">Alle Typen</div>
|
||||
<div class="text-xs text-gray-400 mt-1">Allgemeine Recherche</div>
|
||||
</button>
|
||||
` + data.post_types.map(pt => `
|
||||
<button type="button" onclick="selectPostType('${pt.id}')" id="pt_${pt.id}"
|
||||
class="p-3 rounded-lg border text-left transition-colors bg-brand-bg border-brand-bg-light hover:border-brand-highlight/50 text-white">
|
||||
<div class="font-medium text-sm">${pt.name}</div>
|
||||
<div class="text-xs text-gray-400 mt-1">${pt.analyzed_post_count || 0} Posts analysiert</div>
|
||||
${pt.has_analysis ? '<span class="text-xs text-green-400">Analyse</span>' : ''}
|
||||
</button>
|
||||
`).join('');
|
||||
} else {
|
||||
postTypeArea.classList.add('hidden');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load post types:', error);
|
||||
postTypeArea.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
function selectPostType(typeId) {
|
||||
selectedPostTypeId.value = typeId;
|
||||
|
||||
// Update card styles
|
||||
document.querySelectorAll('[id^="pt_"]').forEach(card => {
|
||||
if (card.id === `pt_${typeId}` || (typeId === '' && card.id === 'pt_all')) {
|
||||
card.className = 'p-3 rounded-lg border text-left transition-colors bg-brand-highlight/20 border-brand-highlight text-white';
|
||||
} else {
|
||||
card.className = 'p-3 rounded-lg border text-left transition-colors bg-brand-bg border-brand-bg-light hover:border-brand-highlight/50 text-white';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const customerId = customerSelect.value;
|
||||
if (!customerId) return;
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<svg class="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg> Recherchiert...';
|
||||
progressArea.classList.remove('hidden');
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('customer_id', customerId);
|
||||
if (selectedPostTypeId.value) {
|
||||
formData.append('post_type_id', selectedPostTypeId.value);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/admin/api/research', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
const taskId = data.task_id;
|
||||
const pollInterval = setInterval(async () => {
|
||||
const statusResponse = await fetch(`/admin/api/tasks/${taskId}`);
|
||||
const status = await statusResponse.json();
|
||||
|
||||
progressBar.style.width = `${status.progress}%`;
|
||||
progressPercent.textContent = `${status.progress}%`;
|
||||
progressMessage.textContent = status.message;
|
||||
|
||||
if (status.status === 'completed') {
|
||||
clearInterval(pollInterval);
|
||||
progressArea.classList.add('hidden');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = '<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg> Research starten';
|
||||
|
||||
// Display topics
|
||||
if (status.topics && status.topics.length > 0) {
|
||||
topicsList.innerHTML = status.topics.map((topic, i) => `
|
||||
<div class="bg-brand-bg rounded-lg p-4 border border-brand-bg-light">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex-1">
|
||||
<span class="inline-block px-2 py-1 text-xs font-medium bg-brand-highlight/20 text-brand-highlight rounded mb-2">${topic.category || 'Topic'}</span>
|
||||
<h4 class="font-semibold text-white">${topic.title}</h4>
|
||||
${topic.angle ? `<p class="text-sm text-brand-highlight/80 mt-1">↳ ${topic.angle}</p>` : ''}
|
||||
${topic.hook_idea ? `<p class="text-sm text-gray-300 mt-2 italic border-l-2 border-brand-highlight/30 pl-2">"${topic.hook_idea.substring(0, 150)}..."</p>` : ''}
|
||||
<p class="text-gray-400 text-sm mt-2">${topic.fact ? topic.fact.substring(0, 200) + '...' : ''}</p>
|
||||
${topic.source ? `<p class="text-gray-500 text-xs mt-2">Quelle: ${topic.source}</p>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
} else {
|
||||
topicsList.innerHTML = '<p class="text-gray-400">Keine Topics gefunden.</p>';
|
||||
}
|
||||
} else if (status.status === 'error') {
|
||||
clearInterval(pollInterval);
|
||||
progressArea.classList.add('hidden');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = '<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg> Research starten';
|
||||
topicsList.innerHTML = `<p class="text-red-400">Fehler: ${status.message}</p>`;
|
||||
}
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
progressArea.classList.add('hidden');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = '<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg> Research starten';
|
||||
topicsList.innerHTML = `<p class="text-red-400">Fehler: ${error.message}</p>`;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
571
src/web/templates/admin/scraped_posts.html
Normal file
571
src/web/templates/admin/scraped_posts.html
Normal file
@@ -0,0 +1,571 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Gescrapte Posts - LinkedIn Posts{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<style>
|
||||
.post-card {
|
||||
background: linear-gradient(135deg, rgba(61, 72, 72, 0.3) 0%, rgba(45, 56, 56, 0.4) 100%);
|
||||
border: 1px solid rgba(61, 72, 72, 0.6);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.post-card:hover {
|
||||
border-color: rgba(255, 199, 0, 0.3);
|
||||
}
|
||||
.post-card.selected {
|
||||
border-color: rgba(255, 199, 0, 0.6);
|
||||
background: linear-gradient(135deg, rgba(255, 199, 0, 0.05) 0%, rgba(45, 56, 56, 0.4) 100%);
|
||||
}
|
||||
.type-badge {
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.type-badge:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
.type-badge.active {
|
||||
background-color: rgba(255, 199, 0, 0.2);
|
||||
border-color: #ffc700;
|
||||
}
|
||||
.post-content-preview {
|
||||
max-height: 150px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
.post-content-preview::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 40px;
|
||||
background: linear-gradient(transparent, rgba(45, 56, 56, 0.9));
|
||||
}
|
||||
.post-content-expanded {
|
||||
max-height: none;
|
||||
}
|
||||
.post-content-expanded::after {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Gescrapte Posts verwalten</h1>
|
||||
<p class="text-gray-400">Posts manuell kategorisieren und Post-Typ-Analyse triggern</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Customer Selection -->
|
||||
<div class="card-bg rounded-xl border p-6 mb-6">
|
||||
<div class="flex flex-wrap items-end gap-4">
|
||||
<div class="flex-1 min-w-64">
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Kunde auswählen</label>
|
||||
<select id="customerSelect" class="w-full input-bg border rounded-lg px-4 py-3 text-white">
|
||||
<option value="">-- Kunde wählen --</option>
|
||||
{% for customer in customers %}
|
||||
<option value="{{ customer.id }}">{{ customer.name }} - {{ customer.company_name or 'Kein Unternehmen' }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button id="classifyAllBtn" class="hidden px-4 py-3 bg-brand-bg hover:bg-brand-bg-light border border-brand-bg-light rounded-lg text-white transition-colors">
|
||||
<span class="flex items-center gap-2">
|
||||
<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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
|
||||
Auto-Klassifizieren
|
||||
</span>
|
||||
</button>
|
||||
<button id="analyzeTypesBtn" class="hidden px-4 py-3 btn-primary rounded-lg font-medium transition-colors">
|
||||
<span class="flex items-center gap-2">
|
||||
<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 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>
|
||||
Post-Typen analysieren
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress Area -->
|
||||
<div id="progressArea" class="hidden card-bg rounded-xl border p-6 mb-6">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span id="progressMessage" class="text-gray-300">Arbeite...</span>
|
||||
<span id="progressPercent" class="text-gray-400">0%</span>
|
||||
</div>
|
||||
<div class="w-full bg-brand-bg-dark rounded-full h-2">
|
||||
<div id="progressBar" class="bg-brand-highlight h-2 rounded-full transition-all duration-300" style="width: 0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats & Post Types -->
|
||||
<div id="statsArea" class="hidden mb-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4">
|
||||
<div class="card-bg rounded-xl border p-4">
|
||||
<div class="text-2xl font-bold text-white" id="totalPostsCount">0</div>
|
||||
<div class="text-sm text-gray-400">Gesamt Posts</div>
|
||||
</div>
|
||||
<div class="card-bg rounded-xl border p-4">
|
||||
<div class="text-2xl font-bold text-green-400" id="classifiedCount">0</div>
|
||||
<div class="text-sm text-gray-400">Klassifiziert</div>
|
||||
</div>
|
||||
<div class="card-bg rounded-xl border p-4">
|
||||
<div class="text-2xl font-bold text-yellow-400" id="unclassifiedCount">0</div>
|
||||
<div class="text-sm text-gray-400">Nicht klassifiziert</div>
|
||||
</div>
|
||||
<div class="card-bg rounded-xl border p-4">
|
||||
<div class="text-2xl font-bold text-brand-highlight" id="postTypesCount">0</div>
|
||||
<div class="text-sm text-gray-400">Post-Typen</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Post Type Filter -->
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
<button onclick="filterByType(null)" id="filter_all" class="type-badge px-3 py-2 rounded-lg border text-sm transition-colors bg-brand-highlight/20 border-brand-highlight text-white">
|
||||
Alle
|
||||
</button>
|
||||
<button onclick="filterByType('unclassified')" id="filter_unclassified" class="type-badge px-3 py-2 rounded-lg border text-sm transition-colors bg-brand-bg border-brand-bg-light hover:border-brand-highlight/50 text-white">
|
||||
Nicht klassifiziert
|
||||
</button>
|
||||
<div id="postTypeFilters" class="contents">
|
||||
<!-- Post type filter buttons will be added here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Posts List -->
|
||||
<div id="postsArea" class="hidden">
|
||||
<div id="postsList" class="space-y-4">
|
||||
<p class="text-gray-400">Lade Posts...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div id="emptyState" class="hidden card-bg rounded-xl border p-12 text-center">
|
||||
<div class="w-20 h-20 bg-brand-bg rounded-2xl flex items-center justify-center mx-auto mb-6">
|
||||
<svg class="w-10 h-10 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/></svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-white mb-2">Keine gescrapten Posts</h3>
|
||||
<p class="text-gray-400 mb-6 max-w-md mx-auto">Für diesen Kunden wurden noch keine LinkedIn Posts gescrapet.</p>
|
||||
</div>
|
||||
|
||||
{% if not customers %}
|
||||
<div class="bg-yellow-900/30 border border-yellow-600 rounded-lg p-4">
|
||||
<p class="text-yellow-300">Noch keine Kunden vorhanden. <a href="/admin/customers/new" class="underline">Erstelle zuerst einen Kunden</a>.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Post Detail Modal -->
|
||||
<div id="postModal" class="fixed inset-0 bg-black/70 hidden items-center justify-center z-50 p-4">
|
||||
<div class="bg-brand-bg-dark rounded-xl border border-brand-bg-light max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col shadow-2xl">
|
||||
<div class="p-4 border-b border-brand-bg-light flex items-center justify-between bg-brand-bg">
|
||||
<h3 class="text-lg font-semibold text-white">Post Details</h3>
|
||||
<button onclick="closeModal()" class="text-gray-400 hover:text-white p-1 hover:bg-brand-bg-light rounded">
|
||||
<svg class="w-6 h-6" 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>
|
||||
</div>
|
||||
<div class="p-6 overflow-y-auto flex-1">
|
||||
<div id="modalContent"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const customerSelect = document.getElementById('customerSelect');
|
||||
const classifyAllBtn = document.getElementById('classifyAllBtn');
|
||||
const analyzeTypesBtn = document.getElementById('analyzeTypesBtn');
|
||||
const progressArea = document.getElementById('progressArea');
|
||||
const progressBar = document.getElementById('progressBar');
|
||||
const progressMessage = document.getElementById('progressMessage');
|
||||
const progressPercent = document.getElementById('progressPercent');
|
||||
const statsArea = document.getElementById('statsArea');
|
||||
const postsArea = document.getElementById('postsArea');
|
||||
const postsList = document.getElementById('postsList');
|
||||
const emptyState = document.getElementById('emptyState');
|
||||
const postTypeFilters = document.getElementById('postTypeFilters');
|
||||
const postModal = document.getElementById('postModal');
|
||||
const modalContent = document.getElementById('modalContent');
|
||||
|
||||
let currentPosts = [];
|
||||
let currentPostTypes = [];
|
||||
let currentFilter = null;
|
||||
|
||||
customerSelect.addEventListener('change', async () => {
|
||||
const customerId = customerSelect.value;
|
||||
|
||||
if (!customerId) {
|
||||
statsArea.classList.add('hidden');
|
||||
postsArea.classList.add('hidden');
|
||||
emptyState.classList.add('hidden');
|
||||
classifyAllBtn.classList.add('hidden');
|
||||
analyzeTypesBtn.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
await loadCustomerData(customerId);
|
||||
});
|
||||
|
||||
async function loadCustomerData(customerId) {
|
||||
// Load post types
|
||||
try {
|
||||
const ptResponse = await fetch(`/admin/api/customers/${customerId}/post-types`);
|
||||
const ptData = await ptResponse.json();
|
||||
currentPostTypes = ptData.post_types || [];
|
||||
|
||||
// Update post type filters
|
||||
postTypeFilters.innerHTML = currentPostTypes.map(pt => `
|
||||
<button onclick="filterByType('${pt.id}')" id="filter_${pt.id}"
|
||||
class="type-badge px-3 py-2 rounded-lg border text-sm transition-colors bg-brand-bg border-brand-bg-light hover:border-brand-highlight/50 text-white">
|
||||
${escapeHtml(pt.name)}
|
||||
<span class="ml-1 text-xs text-gray-400">(${pt.analyzed_post_count || 0})</span>
|
||||
${pt.has_analysis ? '<span class="ml-1 text-green-400">*</span>' : ''}
|
||||
</button>
|
||||
`).join('');
|
||||
|
||||
document.getElementById('postTypesCount').textContent = currentPostTypes.length;
|
||||
} catch (error) {
|
||||
console.error('Failed to load post types:', error);
|
||||
}
|
||||
|
||||
// Load posts
|
||||
try {
|
||||
const response = await fetch(`/admin/api/customers/${customerId}/linkedin-posts`);
|
||||
const data = await response.json();
|
||||
|
||||
console.log('API Response:', data);
|
||||
|
||||
if (data.error) {
|
||||
console.error('API Error:', data.error);
|
||||
postsList.innerHTML = `<p class="text-red-400">API Fehler: ${escapeHtml(data.error)}</p>`;
|
||||
postsArea.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
currentPosts = data.posts || [];
|
||||
console.log(`Loaded ${currentPosts.length} posts`);
|
||||
|
||||
if (currentPosts.length === 0) {
|
||||
statsArea.classList.add('hidden');
|
||||
postsArea.classList.add('hidden');
|
||||
emptyState.classList.remove('hidden');
|
||||
classifyAllBtn.classList.add('hidden');
|
||||
analyzeTypesBtn.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
// Update stats
|
||||
const classified = currentPosts.filter(p => p.post_type_id).length;
|
||||
const unclassified = currentPosts.length - classified;
|
||||
|
||||
document.getElementById('totalPostsCount').textContent = currentPosts.length;
|
||||
document.getElementById('classifiedCount').textContent = classified;
|
||||
document.getElementById('unclassifiedCount').textContent = unclassified;
|
||||
|
||||
statsArea.classList.remove('hidden');
|
||||
postsArea.classList.remove('hidden');
|
||||
emptyState.classList.add('hidden');
|
||||
classifyAllBtn.classList.remove('hidden');
|
||||
analyzeTypesBtn.classList.remove('hidden');
|
||||
|
||||
currentFilter = null;
|
||||
filterByType(null);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load posts:', error);
|
||||
postsList.innerHTML = `<p class="text-red-400">Fehler beim Laden: ${error.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function filterByType(typeId) {
|
||||
currentFilter = typeId;
|
||||
|
||||
// Update filter button styles
|
||||
document.querySelectorAll('.type-badge').forEach(btn => {
|
||||
const btnId = btn.id.replace('filter_', '');
|
||||
const isActive = (typeId === null && btnId === 'all') ||
|
||||
(typeId === 'unclassified' && btnId === 'unclassified') ||
|
||||
(btnId === typeId);
|
||||
|
||||
if (isActive) {
|
||||
btn.classList.add('active', 'bg-brand-highlight/20', 'border-brand-highlight');
|
||||
btn.classList.remove('bg-brand-bg', 'border-brand-bg-light');
|
||||
} else {
|
||||
btn.classList.remove('active', 'bg-brand-highlight/20', 'border-brand-highlight');
|
||||
btn.classList.add('bg-brand-bg', 'border-brand-bg-light');
|
||||
}
|
||||
});
|
||||
|
||||
// Filter posts
|
||||
let filteredPosts = currentPosts;
|
||||
if (typeId === 'unclassified') {
|
||||
filteredPosts = currentPosts.filter(p => !p.post_type_id);
|
||||
} else if (typeId) {
|
||||
filteredPosts = currentPosts.filter(p => p.post_type_id === typeId);
|
||||
}
|
||||
|
||||
renderPosts(filteredPosts);
|
||||
}
|
||||
|
||||
function renderPosts(posts) {
|
||||
if (posts.length === 0) {
|
||||
postsList.innerHTML = '<p class="text-gray-400 text-center py-8">Keine Posts in dieser Kategorie.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
postsList.innerHTML = posts.map((post, index) => {
|
||||
const postType = currentPostTypes.find(pt => pt.id === post.post_type_id);
|
||||
const postText = post.post_text || '';
|
||||
const previewText = postText.substring(0, 300);
|
||||
|
||||
return `
|
||||
<div class="post-card rounded-xl p-4 cursor-pointer" data-post-id="${post.id}" onclick="openPostModal('${post.id}')">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-2 mb-3 flex-wrap">
|
||||
${postType ? `
|
||||
<span class="px-2 py-1 text-xs font-medium bg-brand-highlight/20 text-brand-highlight rounded">
|
||||
${escapeHtml(postType.name)}
|
||||
</span>
|
||||
<span class="text-xs text-gray-500">${post.classification_method || 'unknown'} (${Math.round((post.classification_confidence || 0) * 100)}%)</span>
|
||||
` : `
|
||||
<span class="px-2 py-1 text-xs font-medium bg-gray-600/30 text-gray-400 rounded">
|
||||
Nicht klassifiziert
|
||||
</span>
|
||||
`}
|
||||
${post.posted_at ? `
|
||||
<span class="text-xs text-gray-500">${new Date(post.posted_at).toLocaleDateString('de-DE')}</span>
|
||||
` : ''}
|
||||
${post.engagement_score ? `
|
||||
<span class="text-xs text-gray-500">Engagement: ${post.engagement_score}</span>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
<!-- Content Preview -->
|
||||
<div class="post-content-preview text-gray-300 text-sm whitespace-pre-wrap mb-3">
|
||||
${escapeHtml(previewText)}${postText.length > 300 ? '...' : ''}
|
||||
</div>
|
||||
|
||||
<!-- Click hint & Type badges -->
|
||||
<div class="flex items-center justify-between flex-wrap gap-2" onclick="event.stopPropagation()">
|
||||
<span class="text-xs text-gray-500 italic">Klicken für Vollansicht</span>
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
${currentPostTypes.map(pt => `
|
||||
<button onclick="event.stopPropagation(); classifyPost('${post.id}', '${pt.id}')"
|
||||
class="px-2 py-1 text-xs rounded transition-colors ${post.post_type_id === pt.id ? 'bg-brand-highlight/30 text-brand-highlight border border-brand-highlight' : 'bg-brand-bg hover:bg-brand-bg-light text-gray-300 border border-brand-bg-light'}">
|
||||
${escapeHtml(pt.name)}
|
||||
</button>
|
||||
`).join('')}
|
||||
${post.post_type_id ? `
|
||||
<button onclick="event.stopPropagation(); classifyPost('${post.id}', null)" class="px-2 py-1 text-xs rounded bg-red-900/30 hover:bg-red-900/50 text-red-300 border border-red-900/50">
|
||||
✕
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function classifyPost(postId, postTypeId) {
|
||||
const customerId = customerSelect.value;
|
||||
if (!customerId) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/admin/api/linkedin-posts/${postId}/classify`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ post_type_id: postTypeId })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to classify post');
|
||||
}
|
||||
|
||||
// Update local data
|
||||
const post = currentPosts.find(p => p.id === postId);
|
||||
if (post) {
|
||||
post.post_type_id = postTypeId;
|
||||
post.classification_method = 'manual';
|
||||
post.classification_confidence = 1.0;
|
||||
}
|
||||
|
||||
// Update stats
|
||||
const classified = currentPosts.filter(p => p.post_type_id).length;
|
||||
document.getElementById('classifiedCount').textContent = classified;
|
||||
document.getElementById('unclassifiedCount').textContent = currentPosts.length - classified;
|
||||
|
||||
// Re-render
|
||||
filterByType(currentFilter);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to classify post:', error);
|
||||
alert('Fehler beim Klassifizieren: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function openPostModal(postId) {
|
||||
const post = currentPosts.find(p => p.id === postId);
|
||||
if (!post) return;
|
||||
|
||||
const postType = currentPostTypes.find(pt => pt.id === post.post_type_id);
|
||||
|
||||
modalContent.innerHTML = `
|
||||
<div class="mb-4 flex items-center gap-3 flex-wrap">
|
||||
${postType ? `
|
||||
<span class="px-3 py-1.5 text-sm font-medium bg-brand-highlight/20 text-brand-highlight rounded-lg">
|
||||
${escapeHtml(postType.name)}
|
||||
</span>
|
||||
` : `
|
||||
<span class="px-3 py-1.5 text-sm font-medium bg-gray-600/30 text-gray-400 rounded-lg">
|
||||
Nicht klassifiziert
|
||||
</span>
|
||||
`}
|
||||
${post.posted_at ? `
|
||||
<span class="text-sm text-gray-500">${new Date(post.posted_at).toLocaleDateString('de-DE')}</span>
|
||||
` : ''}
|
||||
${post.engagement_score ? `
|
||||
<span class="text-sm text-gray-500">Engagement: ${post.engagement_score}</span>
|
||||
` : ''}
|
||||
</div>
|
||||
<div class="bg-brand-bg rounded-xl p-6 mb-6 border border-brand-bg-light max-h-[50vh] overflow-y-auto">
|
||||
<div class="whitespace-pre-wrap text-gray-200 font-sans text-base leading-relaxed">${escapeHtml(post.post_text || '')}</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 flex-wrap border-t border-brand-bg-light pt-4">
|
||||
<span class="text-sm text-gray-400 font-medium">Typ zuweisen:</span>
|
||||
${currentPostTypes.map(pt => `
|
||||
<button onclick="classifyPost('${post.id}', '${pt.id}'); closeModal();"
|
||||
class="px-4 py-2 text-sm rounded-lg transition-colors ${post.post_type_id === pt.id ? 'bg-brand-highlight text-brand-bg-dark font-medium' : 'bg-brand-bg hover:bg-brand-bg-light text-gray-300 border border-brand-bg-light'}">
|
||||
${escapeHtml(pt.name)}
|
||||
</button>
|
||||
`).join('')}
|
||||
${post.post_type_id ? `
|
||||
<button onclick="classifyPost('${post.id}', null); closeModal();" class="px-4 py-2 text-sm rounded-lg bg-red-900/30 hover:bg-red-900/50 text-red-300 border border-red-900/50">
|
||||
Klassifizierung entfernen
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
postModal.classList.remove('hidden');
|
||||
postModal.classList.add('flex');
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
postModal.classList.add('hidden');
|
||||
postModal.classList.remove('flex');
|
||||
}
|
||||
|
||||
// Close modal on backdrop click
|
||||
postModal.addEventListener('click', (e) => {
|
||||
if (e.target === postModal) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Close modal on Escape key
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && !postModal.classList.contains('hidden')) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-classify button
|
||||
classifyAllBtn.addEventListener('click', async () => {
|
||||
const customerId = customerSelect.value;
|
||||
if (!customerId) return;
|
||||
|
||||
classifyAllBtn.disabled = true;
|
||||
progressArea.classList.remove('hidden');
|
||||
|
||||
try {
|
||||
const response = await fetch(`/admin/api/customers/${customerId}/classify-posts`, {
|
||||
method: 'POST'
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
const taskId = data.task_id;
|
||||
await pollTask(taskId, async () => {
|
||||
await loadCustomerData(customerId);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Classification failed:', error);
|
||||
alert('Fehler bei der Klassifizierung: ' + error.message);
|
||||
} finally {
|
||||
classifyAllBtn.disabled = false;
|
||||
progressArea.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// Analyze post types button
|
||||
analyzeTypesBtn.addEventListener('click', async () => {
|
||||
const customerId = customerSelect.value;
|
||||
if (!customerId) return;
|
||||
|
||||
analyzeTypesBtn.disabled = true;
|
||||
progressArea.classList.remove('hidden');
|
||||
|
||||
try {
|
||||
const response = await fetch(`/admin/api/customers/${customerId}/analyze-post-types`, {
|
||||
method: 'POST'
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
const taskId = data.task_id;
|
||||
await pollTask(taskId, async () => {
|
||||
await loadCustomerData(customerId);
|
||||
alert('Post-Typ-Analyse abgeschlossen! Die Analysen wurden aktualisiert.');
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Analysis failed:', error);
|
||||
alert('Fehler bei der Analyse: ' + error.message);
|
||||
} finally {
|
||||
analyzeTypesBtn.disabled = false;
|
||||
progressArea.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
async function pollTask(taskId, onComplete) {
|
||||
return new Promise((resolve) => {
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const statusResponse = await fetch(`/admin/api/tasks/${taskId}`);
|
||||
const status = await statusResponse.json();
|
||||
|
||||
progressBar.style.width = `${status.progress}%`;
|
||||
progressPercent.textContent = `${status.progress}%`;
|
||||
progressMessage.textContent = status.message;
|
||||
|
||||
if (status.status === 'completed') {
|
||||
clearInterval(interval);
|
||||
await onComplete();
|
||||
resolve();
|
||||
} else if (status.status === 'error') {
|
||||
clearInterval(interval);
|
||||
alert('Fehler: ' + status.message);
|
||||
resolve();
|
||||
}
|
||||
} catch (error) {
|
||||
clearInterval(interval);
|
||||
console.error('Polling error:', error);
|
||||
resolve();
|
||||
}
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
159
src/web/templates/admin/status.html
Normal file
159
src/web/templates/admin/status.html
Normal file
@@ -0,0 +1,159 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Status - LinkedIn Posts{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Status</h1>
|
||||
<p class="text-gray-400">Übersicht über alle Kunden und deren Setup-Status</p>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="bg-red-900/50 border border-red-500 text-red-200 px-4 py-3 rounded-lg mb-6">
|
||||
<strong>Error:</strong> {{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if customer_statuses %}
|
||||
<div class="grid gap-6">
|
||||
{% for item in customer_statuses %}
|
||||
<div class="card-bg rounded-xl border overflow-hidden">
|
||||
<!-- Customer Header -->
|
||||
<div class="px-6 py-4 border-b border-brand-bg-light">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 rounded-full flex items-center justify-center overflow-hidden {{ 'bg-brand-highlight' if not item.profile_picture else '' }}">
|
||||
{% if item.profile_picture %}
|
||||
<img src="{{ item.profile_picture }}" alt="{{ item.customer.name }}" class="w-full h-full object-cover" loading="lazy" referrerpolicy="no-referrer">
|
||||
{% else %}
|
||||
<span class="text-brand-bg-dark font-bold text-lg">{{ item.customer.name[0] | upper }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-white text-lg">{{ item.customer.name }}</h3>
|
||||
<p class="text-sm text-gray-400">{{ item.customer.company_name or 'Kein Unternehmen' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
{% if item.status.ready_for_posts %}
|
||||
<span class="px-3 py-1.5 bg-green-600/30 text-green-300 rounded-lg text-sm font-medium flex items-center gap-2">
|
||||
<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="M5 13l4 4L19 7"/></svg>
|
||||
Bereit für Posts
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="px-3 py-1.5 bg-yellow-600/30 text-yellow-300 rounded-lg text-sm font-medium flex items-center gap-2">
|
||||
<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="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>
|
||||
Setup unvollständig
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Grid -->
|
||||
<div class="p-6">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<!-- Scraped Posts -->
|
||||
<div class="bg-brand-bg/30 rounded-lg p-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
{% if item.status.has_scraped_posts %}
|
||||
<svg class="w-5 h-5 text-green-500" 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>
|
||||
{% else %}
|
||||
<svg class="w-5 h-5 text-gray-500" 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>
|
||||
{% endif %}
|
||||
<span class="text-sm text-gray-400">Scraped Posts</span>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-white">{{ item.status.scraped_posts_count }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Profile Analysis -->
|
||||
<div class="bg-brand-bg/30 rounded-lg p-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
{% if item.status.has_profile_analysis %}
|
||||
<svg class="w-5 h-5 text-green-500" 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>
|
||||
{% else %}
|
||||
<svg class="w-5 h-5 text-gray-500" 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>
|
||||
{% endif %}
|
||||
<span class="text-sm text-gray-400">Profil Analyse</span>
|
||||
</div>
|
||||
<p class="text-lg font-semibold text-white">{{ 'Vorhanden' if item.status.has_profile_analysis else 'Fehlt' }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Research Topics -->
|
||||
<div class="bg-brand-bg/30 rounded-lg p-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
{% if item.status.research_count > 0 %}
|
||||
<svg class="w-5 h-5 text-green-500" 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>
|
||||
{% else %}
|
||||
<svg class="w-5 h-5 text-gray-500" 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>
|
||||
{% endif %}
|
||||
<span class="text-sm text-gray-400">Research Topics</span>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-white">{{ item.status.research_count }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Generated Posts -->
|
||||
<div class="bg-brand-bg/30 rounded-lg p-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<svg class="w-5 h-5 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 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>
|
||||
<span class="text-sm text-gray-400">Generierte Posts</span>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-white">{{ item.status.posts_count }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Missing Items -->
|
||||
{% if item.status.missing_items %}
|
||||
<div class="bg-yellow-900/20 border border-yellow-600/50 rounded-lg p-4">
|
||||
<h4 class="font-medium text-yellow-300 mb-2 flex items-center gap-2">
|
||||
<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="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>
|
||||
Fehlende Elemente
|
||||
</h4>
|
||||
<ul class="space-y-1">
|
||||
{% for item_missing in item.status.missing_items %}
|
||||
<li class="text-yellow-200/80 text-sm flex items-center gap-2">
|
||||
<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="M9 5l7 7-7 7"/></svg>
|
||||
{{ item_missing }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="flex flex-wrap gap-3 mt-4">
|
||||
{% if not item.status.has_profile_analysis %}
|
||||
<a href="/admin/customers/new" class="px-4 py-2 btn-primary rounded-lg text-sm transition-colors">
|
||||
Setup wiederholen
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if item.status.research_count == 0 %}
|
||||
<a href="/admin/research" class="px-4 py-2 bg-green-600 hover:bg-green-700 rounded-lg text-sm text-white transition-colors">
|
||||
Recherche starten
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if item.status.ready_for_posts %}
|
||||
<a href="/admin/create" class="px-4 py-2 btn-primary rounded-lg text-sm transition-colors">
|
||||
Post erstellen
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="/admin/impersonate/{{ item.customer.id }}" class="px-4 py-2 bg-purple-600 hover:bg-purple-700 rounded-lg text-sm text-white transition-colors flex items-center gap-2">
|
||||
<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="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>
|
||||
Als User einloggen
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card-bg rounded-xl border p-12 text-center">
|
||||
<svg class="w-16 h-16 text-gray-600 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
|
||||
<h3 class="text-xl font-semibold text-white mb-2">Noch keine Kunden</h3>
|
||||
<p class="text-gray-400 mb-6">Erstelle deinen ersten Kunden, um den Status zu sehen.</p>
|
||||
<a href="/admin/customers/new" class="inline-flex items-center gap-2 px-6 py-3 btn-primary font-medium rounded-lg transition-colors">
|
||||
<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="M12 6v6m0 0v6m0-6h6m-6 0H6"/></svg>
|
||||
Neuer Kunde
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
103
src/web/templates/base.html
Normal file
103
src/web/templates/base.html
Normal file
@@ -0,0 +1,103 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}LinkedIn Posts{% endblock %}</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
'brand': {
|
||||
'bg': '#3d4848',
|
||||
'bg-light': '#4a5858',
|
||||
'bg-dark': '#2d3838',
|
||||
'highlight': '#ffc700',
|
||||
'highlight-dark': '#e6b300',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
body { background-color: #3d4848; }
|
||||
.nav-link.active { background-color: #ffc700; color: #2d3838; }
|
||||
.nav-link.active svg { stroke: #2d3838; }
|
||||
.post-content { white-space: pre-wrap; word-wrap: break-word; }
|
||||
.btn-primary { background-color: #ffc700; color: #2d3838; }
|
||||
.btn-primary:hover { background-color: #e6b300; }
|
||||
.sidebar-bg { background-color: #2d3838; }
|
||||
.card-bg { background-color: #4a5858; border-color: #5a6868; }
|
||||
.input-bg { background-color: #3d4848; border-color: #5a6868; }
|
||||
.input-bg:focus { border-color: #ffc700; outline: none; }
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-track { background: #3d4848; }
|
||||
::-webkit-scrollbar-thumb { background: #5a6868; border-radius: 4px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #6a7878; }
|
||||
</style>
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body class="text-gray-100 min-h-screen flex">
|
||||
<!-- Sidebar -->
|
||||
<aside class="w-64 sidebar-bg border-r border-gray-600 flex flex-col fixed h-full">
|
||||
<div class="p-4 border-b border-gray-600">
|
||||
<div class="flex items-center justify-center gap-3">
|
||||
<div>
|
||||
<img src="/static/logo.png" alt="Logo" class="h-15 w-auto">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="flex-1 p-4 space-y-2">
|
||||
<a href="/" 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 == 'home' %}active{% endif %}">
|
||||
<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="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/></svg>
|
||||
Dashboard
|
||||
</a>
|
||||
<a href="/customers/new" 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 == 'new_customer' %}active{% endif %}">
|
||||
<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="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"/></svg>
|
||||
Neuer Kunde
|
||||
</a>
|
||||
<a href="/research" 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 == 'research' %}active{% endif %}">
|
||||
<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
|
||||
Research Topics
|
||||
</a>
|
||||
<a href="/create" 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 == 'create' %}active{% endif %}">
|
||||
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
||||
Post erstellen
|
||||
</a>
|
||||
<a href="/posts" 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 == 'posts' %}active{% endif %}">
|
||||
<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="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/></svg>
|
||||
Alle Posts
|
||||
</a>
|
||||
<a href="/scraped-posts" 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 == 'scraped_posts' %}active{% endif %}">
|
||||
<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="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"/></svg>
|
||||
Post-Typen
|
||||
</a>
|
||||
<a href="/status" 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 == 'status' %}active{% endif %}">
|
||||
<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 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>
|
||||
Status
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div class="p-4 border-t border-gray-600">
|
||||
<a href="/logout" class="flex items-center gap-2 text-gray-400 hover:text-gray-200 text-sm transition-colors">
|
||||
<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="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/></svg>
|
||||
Logout
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="flex-1 ml-64">
|
||||
<div class="p-8">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
539
src/web/templates/create_post.html
Normal file
539
src/web/templates/create_post.html
Normal file
@@ -0,0 +1,539 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Post erstellen - LinkedIn Posts{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Post erstellen</h1>
|
||||
<p class="text-gray-400">Generiere einen neuen LinkedIn Post mit AI</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<!-- Left: Form -->
|
||||
<div>
|
||||
<form id="createPostForm" class="card-bg rounded-xl border p-6 space-y-6">
|
||||
<!-- Customer Selection -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Kunde auswählen</label>
|
||||
<select name="customer_id" id="customerSelect" required class="w-full input-bg border rounded-lg px-4 py-3 text-white">
|
||||
<option value="">-- Kunde wählen --</option>
|
||||
{% for customer in customers %}
|
||||
<option value="{{ customer.id }}">{{ customer.name }} - {{ customer.company_name or 'Kein Unternehmen' }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Post Type Selection -->
|
||||
<div id="postTypeSelectionArea" class="hidden">
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Post-Typ auswählen (optional)</label>
|
||||
<div id="postTypeCards" class="flex flex-wrap gap-2 mb-2">
|
||||
<!-- Post type cards will be loaded here -->
|
||||
</div>
|
||||
<input type="hidden" id="selectedPostTypeId" value="">
|
||||
</div>
|
||||
|
||||
<!-- Topic Selection -->
|
||||
<div id="topicSelectionArea" class="hidden">
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Topic auswählen</label>
|
||||
<div id="topicsList" class="space-y-2 max-h-64 overflow-y-auto">
|
||||
<p class="text-gray-500">Lade Topics...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Topic -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">
|
||||
<span>Oder eigenes Topic eingeben</span>
|
||||
</label>
|
||||
<div class="space-y-3">
|
||||
<input type="text" id="customTopicTitle" placeholder="Topic Titel" class="w-full input-bg border rounded-lg px-4 py-2 text-white">
|
||||
<textarea id="customTopicFact" rows="3" placeholder="Fakt / Kernaussage zum Topic..." class="w-full input-bg border rounded-lg px-4 py-2 text-white"></textarea>
|
||||
<input type="text" id="customTopicSource" placeholder="Quelle (optional)" class="w-full input-bg border rounded-lg px-4 py-2 text-white">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress Area -->
|
||||
<div id="progressArea" class="hidden">
|
||||
<div class="bg-brand-bg rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span id="progressMessage" class="text-gray-300">Starte Post-Erstellung...</span>
|
||||
<span id="progressPercent" class="text-gray-400">0%</span>
|
||||
</div>
|
||||
<div class="w-full bg-brand-bg-dark rounded-full h-2">
|
||||
<div id="progressBar" class="bg-brand-highlight h-2 rounded-full transition-all duration-300" style="width: 0%"></div>
|
||||
</div>
|
||||
<div id="iterationInfo" class="mt-2 text-sm text-gray-400"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" id="submitBtn" class="w-full btn-primary font-medium py-3 rounded-lg transition-colors flex items-center justify-center gap-2">
|
||||
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
||||
Post generieren
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{% if not customers %}
|
||||
<div class="mt-4 bg-yellow-900/30 border border-yellow-600 rounded-lg p-4">
|
||||
<p class="text-yellow-300">Noch keine Kunden vorhanden. <a href="/customers/new" class="underline">Erstelle zuerst einen Kunden</a>.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Right: Result -->
|
||||
<div>
|
||||
<div id="resultArea" class="card-bg rounded-xl border p-6">
|
||||
<h3 class="text-lg font-semibold text-white mb-4">Generierter Post</h3>
|
||||
|
||||
<!-- Live Versions Display -->
|
||||
<div id="liveVersions" class="hidden space-y-4 mb-6">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="text-sm text-gray-400">Live-Vorschau der Iterationen:</span>
|
||||
</div>
|
||||
<div id="versionsContainer" class="space-y-4"></div>
|
||||
</div>
|
||||
|
||||
<div id="postResult">
|
||||
<p class="text-gray-400">Wähle einen Kunden und ein Topic, dann klicke auf "Post generieren"...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const form = document.getElementById('createPostForm');
|
||||
const customerSelect = document.getElementById('customerSelect');
|
||||
const topicSelectionArea = document.getElementById('topicSelectionArea');
|
||||
const topicsList = document.getElementById('topicsList');
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
const progressArea = document.getElementById('progressArea');
|
||||
const progressBar = document.getElementById('progressBar');
|
||||
const progressMessage = document.getElementById('progressMessage');
|
||||
const progressPercent = document.getElementById('progressPercent');
|
||||
const iterationInfo = document.getElementById('iterationInfo');
|
||||
const postResult = document.getElementById('postResult');
|
||||
const liveVersions = document.getElementById('liveVersions');
|
||||
const versionsContainer = document.getElementById('versionsContainer');
|
||||
const postTypeSelectionArea = document.getElementById('postTypeSelectionArea');
|
||||
const postTypeCards = document.getElementById('postTypeCards');
|
||||
const selectedPostTypeIdInput = document.getElementById('selectedPostTypeId');
|
||||
|
||||
let selectedTopic = null;
|
||||
let currentVersionIndex = 0;
|
||||
let currentPostTypes = [];
|
||||
let currentTopics = [];
|
||||
|
||||
function renderVersions(versions, feedbackList) {
|
||||
if (!versions || versions.length === 0) {
|
||||
liveVersions.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
liveVersions.classList.remove('hidden');
|
||||
|
||||
// Build version tabs and content
|
||||
let html = `
|
||||
<div class="flex gap-2 mb-4 flex-wrap">
|
||||
${versions.map((_, i) => `
|
||||
<button onclick="showVersion(${i})" id="versionTab${i}"
|
||||
class="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors
|
||||
${i === currentVersionIndex ? 'bg-brand-highlight text-brand-bg-dark' : 'bg-brand-bg text-gray-300 hover:bg-brand-bg-light'}">
|
||||
V${i + 1}
|
||||
${feedbackList[i] ? `<span class="ml-1 text-xs opacity-75">(${feedbackList[i].overall_score || '?'})</span>` : ''}
|
||||
</button>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Show current version
|
||||
const currentVersion = versions[currentVersionIndex];
|
||||
const currentFeedback = feedbackList[currentVersionIndex];
|
||||
|
||||
html += `
|
||||
<div class="grid grid-cols-1 ${currentFeedback ? 'lg:grid-cols-2' : ''} gap-4">
|
||||
<div class="bg-brand-bg/50 rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-sm font-medium text-gray-300">Version ${currentVersionIndex + 1}</span>
|
||||
${currentFeedback ? `
|
||||
<span class="px-2 py-0.5 text-xs rounded ${currentFeedback.approved ? 'bg-green-600/30 text-green-300' : 'bg-yellow-600/30 text-yellow-300'}">
|
||||
${currentFeedback.approved ? 'Approved' : `Score: ${currentFeedback.overall_score}/100`}
|
||||
</span>
|
||||
` : '<span class="text-xs text-gray-500">Wird bewertet...</span>'}
|
||||
</div>
|
||||
<pre class="whitespace-pre-wrap text-gray-200 font-sans text-sm max-h-96 overflow-y-auto">${currentVersion}</pre>
|
||||
</div>
|
||||
${currentFeedback ? `
|
||||
<div class="bg-brand-bg/30 rounded-lg p-4 border border-brand-bg-light">
|
||||
<span class="text-sm font-medium text-gray-300 block mb-2">Kritik</span>
|
||||
<p class="text-sm text-gray-400 mb-3">${currentFeedback.feedback || 'Keine Kritik'}</p>
|
||||
${currentFeedback.improvements && currentFeedback.improvements.length > 0 ? `
|
||||
<div class="mt-2">
|
||||
<span class="text-xs font-medium text-gray-400">Verbesserungen:</span>
|
||||
<ul class="mt-1 space-y-1">
|
||||
${currentFeedback.improvements.map(imp => `
|
||||
<li class="text-xs text-gray-500 flex items-start gap-1">
|
||||
<span class="text-yellow-500">•</span> ${imp}
|
||||
</li>
|
||||
`).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
` : ''}
|
||||
${currentFeedback.scores ? `
|
||||
<div class="mt-3 pt-3 border-t border-brand-bg-light">
|
||||
<div class="grid grid-cols-3 gap-2 text-xs">
|
||||
<div class="text-center">
|
||||
<div class="text-gray-500">Authentizität</div>
|
||||
<div class="font-medium text-gray-300">${currentFeedback.scores.authenticity_and_style || '?'}/40</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-gray-500">Content</div>
|
||||
<div class="font-medium text-gray-300">${currentFeedback.scores.content_quality || '?'}/35</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-gray-500">Technik</div>
|
||||
<div class="font-medium text-gray-300">${currentFeedback.scores.technical_execution || '?'}/25</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
versionsContainer.innerHTML = html;
|
||||
}
|
||||
|
||||
function showVersion(index) {
|
||||
currentVersionIndex = index;
|
||||
// Get cached versions from progress store
|
||||
const cachedData = window.lastProgressData;
|
||||
if (cachedData) {
|
||||
renderVersions(cachedData.versions, cachedData.feedback_list);
|
||||
}
|
||||
}
|
||||
|
||||
// Load topics and post types when customer is selected
|
||||
customerSelect.addEventListener('change', async () => {
|
||||
const customerId = customerSelect.value;
|
||||
selectedPostTypeIdInput.value = '';
|
||||
|
||||
if (!customerId) {
|
||||
topicSelectionArea.classList.add('hidden');
|
||||
postTypeSelectionArea.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
topicSelectionArea.classList.remove('hidden');
|
||||
topicsList.innerHTML = '<p class="text-gray-500">Lade Topics...</p>';
|
||||
|
||||
// Load post types
|
||||
try {
|
||||
const ptResponse = await fetch(`/api/customers/${customerId}/post-types`);
|
||||
const ptData = await ptResponse.json();
|
||||
|
||||
if (ptData.post_types && ptData.post_types.length > 0) {
|
||||
currentPostTypes = ptData.post_types;
|
||||
postTypeSelectionArea.classList.remove('hidden');
|
||||
|
||||
postTypeCards.innerHTML = `
|
||||
<button type="button" onclick="selectPostTypeForCreate('')" id="ptc_all"
|
||||
class="px-3 py-2 rounded-lg border text-sm transition-colors bg-brand-highlight/20 border-brand-highlight text-white">
|
||||
Alle Typen
|
||||
</button>
|
||||
` + ptData.post_types.map(pt => `
|
||||
<button type="button" onclick="selectPostTypeForCreate('${pt.id}')" id="ptc_${pt.id}"
|
||||
class="px-3 py-2 rounded-lg border text-sm transition-colors bg-brand-bg border-brand-bg-light hover:border-brand-highlight/50 text-white">
|
||||
${pt.name}
|
||||
${pt.has_analysis ? '<span class="ml-1 text-green-400 text-xs">*</span>' : ''}
|
||||
</button>
|
||||
`).join('');
|
||||
} else {
|
||||
postTypeSelectionArea.classList.add('hidden');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load post types:', error);
|
||||
postTypeSelectionArea.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Load topics
|
||||
try {
|
||||
const response = await fetch(`/api/customers/${customerId}/topics`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.topics && data.topics.length > 0) {
|
||||
renderTopicsList(data);
|
||||
} else {
|
||||
// No topics available - show helpful message
|
||||
let message = '';
|
||||
if (data.used_count > 0) {
|
||||
message = `<div class="text-center py-4">
|
||||
<p class="text-gray-400 mb-2">Alle ${data.used_count} Topics wurden bereits verwendet.</p>
|
||||
<a href="/research" class="text-brand-highlight hover:underline">Neue Topics recherchieren</a>
|
||||
<p class="text-gray-500 text-sm mt-2">oder gib unten ein eigenes Topic ein.</p>
|
||||
</div>`;
|
||||
} else {
|
||||
message = `<div class="text-center py-4">
|
||||
<p class="text-gray-400 mb-2">Keine Topics gefunden.</p>
|
||||
<a href="/research" class="text-brand-highlight hover:underline">Recherche starten</a>
|
||||
<p class="text-gray-500 text-sm mt-2">oder gib unten ein eigenes Topic ein.</p>
|
||||
</div>`;
|
||||
}
|
||||
topicsList.innerHTML = message;
|
||||
}
|
||||
} catch (error) {
|
||||
topicsList.innerHTML = `<p class="text-red-400">Fehler beim Laden: ${error.message}</p>`;
|
||||
}
|
||||
});
|
||||
|
||||
// Clear selected topic when custom topic is entered
|
||||
['customTopicTitle', 'customTopicFact', 'customTopicSource'].forEach(id => {
|
||||
document.getElementById(id).addEventListener('input', () => {
|
||||
selectedTopic = null;
|
||||
document.querySelectorAll('input[name="topic"]').forEach(radio => radio.checked = false);
|
||||
});
|
||||
});
|
||||
|
||||
function selectPostTypeForCreate(typeId) {
|
||||
selectedPostTypeIdInput.value = typeId;
|
||||
|
||||
// Update card styles
|
||||
document.querySelectorAll('[id^="ptc_"]').forEach(card => {
|
||||
if (card.id === `ptc_${typeId}` || (typeId === '' && card.id === 'ptc_all')) {
|
||||
card.className = 'px-3 py-2 rounded-lg border text-sm transition-colors bg-brand-highlight/20 border-brand-highlight text-white';
|
||||
} else {
|
||||
card.className = 'px-3 py-2 rounded-lg border text-sm transition-colors bg-brand-bg border-brand-bg-light hover:border-brand-highlight/50 text-white';
|
||||
}
|
||||
});
|
||||
|
||||
// Optionally reload topics filtered by post type
|
||||
const customerId = customerSelect.value;
|
||||
if (customerId) {
|
||||
loadTopicsForPostType(customerId, typeId);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTopicsForPostType(customerId, postTypeId) {
|
||||
topicsList.innerHTML = '<p class="text-gray-500">Lade Topics...</p>';
|
||||
|
||||
try {
|
||||
let url = `/api/customers/${customerId}/topics`;
|
||||
if (postTypeId) {
|
||||
url += `?post_type_id=${postTypeId}`;
|
||||
}
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.topics && data.topics.length > 0) {
|
||||
renderTopicsList(data);
|
||||
} else {
|
||||
let message = '';
|
||||
if (data.used_count > 0) {
|
||||
message = `<div class="text-center py-4">
|
||||
<p class="text-gray-400 mb-2">Alle ${data.used_count} Topics wurden bereits verwendet.</p>
|
||||
<a href="/research" class="text-brand-highlight hover:underline">Neue Topics recherchieren</a>
|
||||
<p class="text-gray-500 text-sm mt-2">oder gib unten ein eigenes Topic ein.</p>
|
||||
</div>`;
|
||||
} else {
|
||||
message = `<div class="text-center py-4">
|
||||
<p class="text-gray-400 mb-2">Keine Topics gefunden${postTypeId ? ' für diesen Post-Typ' : ''}.</p>
|
||||
<a href="/research" class="text-brand-highlight hover:underline">Recherche starten</a>
|
||||
<p class="text-gray-500 text-sm mt-2">oder gib unten ein eigenes Topic ein.</p>
|
||||
</div>`;
|
||||
}
|
||||
topicsList.innerHTML = message;
|
||||
}
|
||||
} catch (error) {
|
||||
topicsList.innerHTML = `<p class="text-red-400">Fehler beim Laden: ${error.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderTopicsList(data) {
|
||||
// Store topics in global array for safe access
|
||||
currentTopics = data.topics;
|
||||
|
||||
// Reset selected topic when list is re-rendered
|
||||
selectedTopic = null;
|
||||
|
||||
let statsHtml = '';
|
||||
if (data.used_count > 0) {
|
||||
statsHtml = `<p class="text-xs text-gray-500 mb-3">${data.available_count} verfügbar · ${data.used_count} bereits verwendet</p>`;
|
||||
}
|
||||
|
||||
topicsList.innerHTML = statsHtml + data.topics.map((topic, i) => `
|
||||
<label class="flex items-start gap-3 p-3 bg-brand-bg/50 rounded-lg cursor-pointer hover:bg-brand-bg transition-colors border border-transparent hover:border-brand-highlight/30">
|
||||
<input type="radio" name="topic" value="${i}" class="mt-1 text-brand-highlight" data-topic-index="${i}">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<span class="inline-block px-2 py-0.5 text-xs font-medium bg-brand-highlight/20 text-brand-highlight rounded">${escapeHtml(topic.category || 'Topic')}</span>
|
||||
${topic.target_post_type_id ? `<span class="text-xs text-gray-500">Typ-spezifisch</span>` : ''}
|
||||
${topic.source ? `<span class="text-xs text-gray-500">🔗 ${escapeHtml(topic.source.substring(0, 30))}${topic.source.length > 30 ? '...' : ''}</span>` : ''}
|
||||
</div>
|
||||
<p class="font-medium text-white">${escapeHtml(topic.title)}</p>
|
||||
${topic.angle ? `<p class="text-xs text-brand-highlight/80 mt-1">→ ${escapeHtml(topic.angle)}</p>` : ''}
|
||||
${topic.hook_idea ? `<p class="text-sm text-gray-300 mt-2 italic border-l-2 border-brand-highlight/30 pl-2">"${escapeHtml(topic.hook_idea.substring(0, 120))}${topic.hook_idea.length > 120 ? '...' : ''}"</p>` : ''}
|
||||
${topic.key_facts && topic.key_facts.length > 0 ? `
|
||||
<div class="mt-2 flex flex-wrap gap-1">
|
||||
${topic.key_facts.slice(0, 2).map(f => `<span class="text-xs bg-brand-bg-dark px-2 py-0.5 rounded text-gray-400">📊 ${escapeHtml(f.substring(0, 40))}${f.length > 40 ? '...' : ''}</span>`).join('')}
|
||||
</div>
|
||||
` : (topic.fact ? `<p class="text-sm text-gray-400 mt-1">${escapeHtml(topic.fact.substring(0, 100))}...</p>` : '')}
|
||||
${topic.why_this_person ? `<p class="text-xs text-gray-500 mt-2">💡 ${escapeHtml(topic.why_this_person.substring(0, 80))}${topic.why_this_person.length > 80 ? '...' : ''}</p>` : ''}
|
||||
</div>
|
||||
</label>
|
||||
`).join('');
|
||||
|
||||
// Add event listeners to radio buttons
|
||||
document.querySelectorAll('input[name="topic"]').forEach(radio => {
|
||||
radio.addEventListener('change', () => {
|
||||
const index = parseInt(radio.dataset.topicIndex, 10);
|
||||
selectedTopic = currentTopics[index];
|
||||
// Clear custom topic fields
|
||||
document.getElementById('customTopicTitle').value = '';
|
||||
document.getElementById('customTopicFact').value = '';
|
||||
document.getElementById('customTopicSource').value = '';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Helper function to escape HTML special characters
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const customerId = customerSelect.value;
|
||||
if (!customerId) {
|
||||
alert('Bitte wähle einen Kunden aus.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get topic (either selected or custom)
|
||||
let topic;
|
||||
const customTitle = document.getElementById('customTopicTitle').value.trim();
|
||||
const customFact = document.getElementById('customTopicFact').value.trim();
|
||||
|
||||
if (customTitle && customFact) {
|
||||
topic = {
|
||||
title: customTitle,
|
||||
fact: customFact,
|
||||
source: document.getElementById('customTopicSource').value.trim() || null,
|
||||
category: 'Custom'
|
||||
};
|
||||
} else if (selectedTopic) {
|
||||
topic = selectedTopic;
|
||||
} else {
|
||||
alert('Bitte wähle ein Topic aus oder gib ein eigenes ein.');
|
||||
return;
|
||||
}
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<svg class="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg> Generiert...';
|
||||
progressArea.classList.remove('hidden');
|
||||
postResult.innerHTML = '<p class="text-gray-400">Post wird generiert...</p>';
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('customer_id', customerId);
|
||||
formData.append('topic_json', JSON.stringify(topic));
|
||||
if (selectedPostTypeIdInput.value) {
|
||||
formData.append('post_type_id', selectedPostTypeIdInput.value);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/posts', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
const taskId = data.task_id;
|
||||
currentVersionIndex = 0;
|
||||
window.lastProgressData = null;
|
||||
|
||||
const pollInterval = setInterval(async () => {
|
||||
const statusResponse = await fetch(`/api/tasks/${taskId}`);
|
||||
const status = await statusResponse.json();
|
||||
|
||||
progressBar.style.width = `${status.progress}%`;
|
||||
progressPercent.textContent = `${status.progress}%`;
|
||||
progressMessage.textContent = status.message;
|
||||
|
||||
if (status.iteration !== undefined) {
|
||||
iterationInfo.textContent = `Iteration ${status.iteration}/${status.max_iterations}`;
|
||||
}
|
||||
|
||||
// Update live versions display
|
||||
if (status.versions && status.versions.length > 0) {
|
||||
window.lastProgressData = status;
|
||||
// Auto-select latest version
|
||||
if (status.versions.length > currentVersionIndex + 1) {
|
||||
currentVersionIndex = status.versions.length - 1;
|
||||
}
|
||||
renderVersions(status.versions, status.feedback_list || []);
|
||||
postResult.innerHTML = '<p class="text-gray-400">Siehe Live-Vorschau oben...</p>';
|
||||
}
|
||||
|
||||
if (status.status === 'completed') {
|
||||
clearInterval(pollInterval);
|
||||
progressArea.classList.add('hidden');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = '<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg> Post generieren';
|
||||
|
||||
// Keep live versions visible but update header
|
||||
const result = status.result;
|
||||
|
||||
postResult.innerHTML = `
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-2 text-sm flex-wrap">
|
||||
<span class="px-2 py-1 rounded ${result.approved ? 'bg-green-600/30 text-green-300' : 'bg-yellow-600/30 text-yellow-300'}">
|
||||
${result.approved ? 'Approved' : 'Review needed'}
|
||||
</span>
|
||||
<span class="text-gray-400">Score: ${result.final_score}/100</span>
|
||||
<span class="text-gray-400">Iterations: ${result.iterations}</span>
|
||||
</div>
|
||||
<div class="text-sm text-gray-400 mb-2">Finaler Post:</div>
|
||||
<div class="bg-brand-bg/50 rounded-lg p-4">
|
||||
<pre class="whitespace-pre-wrap text-gray-200 font-sans">${result.final_post}</pre>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="copyPost()" class="px-4 py-2 bg-brand-bg hover:bg-brand-bg-light rounded-lg text-sm text-white transition-colors">
|
||||
In Zwischenablage kopieren
|
||||
</button>
|
||||
<button onclick="toggleVersions()" class="px-4 py-2 bg-brand-bg hover:bg-brand-bg-light rounded-lg text-sm text-white transition-colors">
|
||||
Versionen ${liveVersions.classList.contains('hidden') ? 'anzeigen' : 'ausblenden'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else if (status.status === 'error') {
|
||||
clearInterval(pollInterval);
|
||||
progressArea.classList.add('hidden');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = '<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg> Post generieren';
|
||||
postResult.innerHTML = `<p class="text-red-400">Fehler: ${status.message}</p>`;
|
||||
}
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
progressArea.classList.add('hidden');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = '<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg> Post generieren';
|
||||
postResult.innerHTML = `<p class="text-red-400">Fehler: ${error.message}</p>`;
|
||||
}
|
||||
});
|
||||
|
||||
function copyPost() {
|
||||
const postText = document.querySelector('#postResult pre').textContent;
|
||||
navigator.clipboard.writeText(postText).then(() => {
|
||||
alert('Post in Zwischenablage kopiert!');
|
||||
});
|
||||
}
|
||||
|
||||
function toggleVersions() {
|
||||
liveVersions.classList.toggle('hidden');
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
97
src/web/templates/dashboard.html
Normal file
97
src/web/templates/dashboard.html
Normal file
@@ -0,0 +1,97 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Dashboard - LinkedIn Posts{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Dashboard</h1>
|
||||
<p class="text-gray-400">Willkommen zum LinkedIn Post Creation System</p>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="bg-red-900/50 border border-red-500 text-red-200 px-4 py-3 rounded-lg mb-6">
|
||||
<strong>Error:</strong> {{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<div class="card-bg rounded-xl border p-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 bg-brand-highlight/20 rounded-lg flex items-center justify-center">
|
||||
<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="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-gray-400 text-sm">Kunden</p>
|
||||
<p class="text-2xl font-bold text-white">{{ customers_count or 0 }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-bg rounded-xl border p-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 bg-green-600/20 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-green-400" 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"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-gray-400 text-sm">Generierte Posts</p>
|
||||
<p class="text-2xl font-bold text-white">{{ total_posts or 0 }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-bg rounded-xl border p-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 bg-brand-highlight/20 rounded-lg flex items-center justify-center">
|
||||
<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="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-gray-400 text-sm">AI Agents</p>
|
||||
<p class="text-2xl font-bold text-white">5</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="card-bg rounded-xl border p-6">
|
||||
<h2 class="text-xl font-semibold text-white mb-4">Schnellaktionen</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<a href="/customers/new" class="flex items-center gap-3 p-4 bg-brand-bg rounded-lg hover:bg-brand-bg-light transition-colors">
|
||||
<div class="w-10 h-10 btn-primary rounded-lg flex items-center justify-center">
|
||||
<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="M12 6v6m0 0v6m0-6h6m-6 0H6"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-white">Neuer Kunde</p>
|
||||
<p class="text-sm text-gray-400">Setup starten</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="/research" class="flex items-center gap-3 p-4 bg-brand-bg rounded-lg hover:bg-brand-bg-light transition-colors">
|
||||
<div class="w-10 h-10 btn-primary rounded-lg flex items-center justify-center">
|
||||
<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-white">Research</p>
|
||||
<p class="text-sm text-gray-400">Topics finden</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="/create" class="flex items-center gap-3 p-4 bg-brand-bg rounded-lg hover:bg-brand-bg-light transition-colors">
|
||||
<div class="w-10 h-10 btn-primary rounded-lg flex items-center justify-center">
|
||||
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-white">Post erstellen</p>
|
||||
<p class="text-sm text-gray-400">Content generieren</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="/posts" class="flex items-center gap-3 p-4 bg-brand-bg rounded-lg hover:bg-brand-bg-light transition-colors">
|
||||
<div class="w-10 h-10 btn-primary rounded-lg flex items-center justify-center">
|
||||
<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="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-white">Alle Posts</p>
|
||||
<p class="text-sm text-gray-400">Übersicht anzeigen</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
72
src/web/templates/login.html
Normal file
72
src/web/templates/login.html
Normal file
@@ -0,0 +1,72 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Login - LinkedIn Posts</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
'brand': {
|
||||
'bg': '#3d4848',
|
||||
'bg-light': '#4a5858',
|
||||
'bg-dark': '#2d3838',
|
||||
'highlight': '#ffc700',
|
||||
'highlight-dark': '#e6b300',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
body { background-color: #3d4848; }
|
||||
.btn-primary { background-color: #ffc700; color: #2d3838; }
|
||||
.btn-primary:hover { background-color: #e6b300; }
|
||||
.card-bg { background-color: #4a5858; border-color: #5a6868; }
|
||||
.input-bg { background-color: #3d4848; border-color: #5a6868; }
|
||||
.input-bg:focus { border-color: #ffc700; outline: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="text-gray-100 min-h-screen flex items-center justify-center">
|
||||
<div class="w-full max-w-md">
|
||||
<div class="card-bg rounded-xl border p-8">
|
||||
<div class="text-center mb-8">
|
||||
<img src="/static/logo.png" alt="Logo" class="h-16 w-auto mx-auto mb-4">
|
||||
<h1 class="text-2xl font-bold text-white mb-2">LinkedIn Posts</h1>
|
||||
<p class="text-gray-400">AI Workflow System</p>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="bg-red-900/50 border border-red-500 text-red-200 px-4 py-3 rounded-lg mb-6">
|
||||
Falsches Passwort. Bitte versuche es erneut.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="POST" action="/login" class="space-y-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Passwort</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
required
|
||||
autofocus
|
||||
class="w-full input-bg border rounded-lg px-4 py-3 text-white"
|
||||
placeholder="Passwort eingeben..."
|
||||
>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full btn-primary font-medium py-3 rounded-lg transition-colors"
|
||||
>
|
||||
Anmelden
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
274
src/web/templates/new_customer.html
Normal file
274
src/web/templates/new_customer.html
Normal file
@@ -0,0 +1,274 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Neuer Kunde - LinkedIn Posts{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Neuer Kunde</h1>
|
||||
<p class="text-gray-400">Richte einen neuen Kunden ein und starte das initiale Setup</p>
|
||||
</div>
|
||||
|
||||
<div class="max-w-2xl">
|
||||
<form id="customerForm" class="card-bg rounded-xl border p-6 space-y-6">
|
||||
<!-- Basic Info -->
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-white mb-4">Basis-Informationen</h3>
|
||||
<div class="grid gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Name *</label>
|
||||
<input type="text" name="name" required class="w-full input-bg border rounded-lg px-4 py-2 text-white">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">LinkedIn URL *</label>
|
||||
<input type="url" name="linkedin_url" required placeholder="https://www.linkedin.com/in/username" class="w-full input-bg border rounded-lg px-4 py-2 text-white">
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Firma</label>
|
||||
<input type="text" name="company_name" class="w-full input-bg border rounded-lg px-4 py-2 text-white">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">E-Mail</label>
|
||||
<input type="email" name="email" class="w-full input-bg border rounded-lg px-4 py-2 text-white">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Persona -->
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-white mb-4">Persona & Stil</h3>
|
||||
<div class="grid gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Persona</label>
|
||||
<textarea name="persona" rows="3" placeholder="Beschreibe die Expertise, Positionierung und den Charakter der Person..." class="w-full input-bg border rounded-lg px-4 py-2 text-white"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Ansprache</label>
|
||||
<input type="text" name="form_of_address" placeholder="z.B. Duzen (Du/Euch) oder Siezen (Sie)" class="w-full input-bg border rounded-lg px-4 py-2 text-white">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Style Guide</label>
|
||||
<textarea name="style_guide" rows="3" placeholder="Beschreibe den Schreibstil, Tonalität und Richtlinien..." class="w-full input-bg border rounded-lg px-4 py-2 text-white"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Post Types -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-white">Post-Typen</h3>
|
||||
<button type="button" id="addPostTypeBtn" class="text-sm text-brand-highlight hover:underline">+ Post-Typ hinzufügen</button>
|
||||
</div>
|
||||
<p class="text-sm text-gray-400 mb-4">Definiere verschiedene Arten von Posts (z.B. "Thought Leader", "Case Study", "How-To"). Diese werden zur Kategorisierung und typ-spezifischen Analyse verwendet.</p>
|
||||
|
||||
<div id="postTypesContainer" class="space-y-4">
|
||||
<!-- Post type entries will be added here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress Area -->
|
||||
<div id="progressArea" class="hidden">
|
||||
<div class="bg-brand-bg rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span id="progressMessage" class="text-gray-300">Starte Setup...</span>
|
||||
<span id="progressPercent" class="text-gray-400">0%</span>
|
||||
</div>
|
||||
<div class="w-full bg-brand-bg-dark rounded-full h-2">
|
||||
<div id="progressBar" class="bg-brand-highlight h-2 rounded-full transition-all duration-300" style="width: 0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Result Area -->
|
||||
<div id="resultArea" class="hidden">
|
||||
<div id="successResult" class="hidden bg-green-900/30 border border-green-500 rounded-lg p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-6 h-6 text-green-500" 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>
|
||||
<span class="text-green-300">Setup erfolgreich abgeschlossen!</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="errorResult" class="hidden bg-red-900/30 border border-red-500 rounded-lg p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-6 h-6 text-red-500" 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>
|
||||
<span id="errorMessage" class="text-red-300">Fehler beim Setup</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<div class="flex gap-4">
|
||||
<button type="submit" id="submitBtn" class="flex-1 btn-primary font-medium py-3 rounded-lg transition-colors">
|
||||
Setup starten
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const form = document.getElementById('customerForm');
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
const progressArea = document.getElementById('progressArea');
|
||||
const resultArea = document.getElementById('resultArea');
|
||||
const progressBar = document.getElementById('progressBar');
|
||||
const progressMessage = document.getElementById('progressMessage');
|
||||
const progressPercent = document.getElementById('progressPercent');
|
||||
const postTypesContainer = document.getElementById('postTypesContainer');
|
||||
const addPostTypeBtn = document.getElementById('addPostTypeBtn');
|
||||
|
||||
let postTypeIndex = 0;
|
||||
|
||||
function createPostTypeEntry() {
|
||||
const index = postTypeIndex++;
|
||||
const entry = document.createElement('div');
|
||||
entry.className = 'bg-brand-bg rounded-lg p-4 border border-brand-bg-light';
|
||||
entry.id = `postType_${index}`;
|
||||
entry.innerHTML = `
|
||||
<div class="flex justify-between items-start mb-3">
|
||||
<span class="text-sm font-medium text-gray-300">Post-Typ ${index + 1}</span>
|
||||
<button type="button" onclick="removePostType(${index})" class="text-red-400 hover:text-red-300 text-sm">Entfernen</button>
|
||||
</div>
|
||||
<div class="grid gap-3">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-400 mb-1">Name *</label>
|
||||
<input type="text" data-pt-field="name" data-pt-index="${index}" required placeholder="z.B. Thought Leader" class="w-full input-bg border rounded-lg px-3 py-2 text-white text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-400 mb-1">Beschreibung</label>
|
||||
<input type="text" data-pt-field="description" data-pt-index="${index}" placeholder="Kurze Beschreibung" class="w-full input-bg border rounded-lg px-3 py-2 text-white text-sm">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-400 mb-1">Identifizierende Hashtags (kommagetrennt)</label>
|
||||
<input type="text" data-pt-field="hashtags" data-pt-index="${index}" placeholder="#ThoughtLeader, #Insight, #Leadership" class="w-full input-bg border rounded-lg px-3 py-2 text-white text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-400 mb-1">Keywords (kommagetrennt)</label>
|
||||
<input type="text" data-pt-field="keywords" data-pt-index="${index}" placeholder="Erfahrung, Learnings, Meinung" class="w-full input-bg border rounded-lg px-3 py-2 text-white text-sm">
|
||||
</div>
|
||||
<details class="mt-2">
|
||||
<summary class="text-xs text-gray-400 cursor-pointer hover:text-gray-300">Erweiterte Eigenschaften</summary>
|
||||
<div class="mt-3 grid gap-3">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-400 mb-1">Zweck</label>
|
||||
<input type="text" data-pt-field="purpose" data-pt-index="${index}" placeholder="z.B. Expertise zeigen, Meinungsführerschaft etablieren" class="w-full input-bg border rounded-lg px-3 py-2 text-white text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-400 mb-1">Typische Tonalität</label>
|
||||
<input type="text" data-pt-field="tone" data-pt-index="${index}" placeholder="z.B. reflektiert, provokativ, inspirierend" class="w-full input-bg border rounded-lg px-3 py-2 text-white text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-400 mb-1">Zielgruppe</label>
|
||||
<input type="text" data-pt-field="target_audience" data-pt-index="${index}" placeholder="z.B. Führungskräfte, Entscheider" class="w-full input-bg border rounded-lg px-3 py-2 text-white text-sm">
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
`;
|
||||
postTypesContainer.appendChild(entry);
|
||||
}
|
||||
|
||||
function removePostType(index) {
|
||||
const entry = document.getElementById(`postType_${index}`);
|
||||
if (entry) {
|
||||
entry.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function collectPostTypes() {
|
||||
const postTypes = [];
|
||||
const entries = postTypesContainer.querySelectorAll('[id^="postType_"]');
|
||||
|
||||
entries.forEach(entry => {
|
||||
const index = entry.id.split('_')[1];
|
||||
const name = entry.querySelector(`[data-pt-field="name"][data-pt-index="${index}"]`)?.value?.trim();
|
||||
|
||||
if (name) {
|
||||
const hashtagsRaw = entry.querySelector(`[data-pt-field="hashtags"][data-pt-index="${index}"]`)?.value || '';
|
||||
const keywordsRaw = entry.querySelector(`[data-pt-field="keywords"][data-pt-index="${index}"]`)?.value || '';
|
||||
|
||||
postTypes.push({
|
||||
name: name,
|
||||
description: entry.querySelector(`[data-pt-field="description"][data-pt-index="${index}"]`)?.value?.trim() || null,
|
||||
identifying_hashtags: hashtagsRaw.split(',').map(h => h.trim()).filter(h => h),
|
||||
identifying_keywords: keywordsRaw.split(',').map(k => k.trim()).filter(k => k),
|
||||
semantic_properties: {
|
||||
purpose: entry.querySelector(`[data-pt-field="purpose"][data-pt-index="${index}"]`)?.value?.trim() || null,
|
||||
typical_tone: entry.querySelector(`[data-pt-field="tone"][data-pt-index="${index}"]`)?.value?.trim() || null,
|
||||
target_audience: entry.querySelector(`[data-pt-field="target_audience"][data-pt-index="${index}"]`)?.value?.trim() || null
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return postTypes;
|
||||
}
|
||||
|
||||
addPostTypeBtn.addEventListener('click', createPostTypeEntry);
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Wird gestartet...';
|
||||
progressArea.classList.remove('hidden');
|
||||
resultArea.classList.add('hidden');
|
||||
|
||||
const formData = new FormData(form);
|
||||
|
||||
// Add post types as JSON
|
||||
const postTypes = collectPostTypes();
|
||||
if (postTypes.length > 0) {
|
||||
formData.append('post_types_json', JSON.stringify(postTypes));
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/customers', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
// Poll for progress
|
||||
const taskId = data.task_id;
|
||||
const pollInterval = setInterval(async () => {
|
||||
const statusResponse = await fetch(`/api/tasks/${taskId}`);
|
||||
const status = await statusResponse.json();
|
||||
|
||||
progressBar.style.width = `${status.progress}%`;
|
||||
progressPercent.textContent = `${status.progress}%`;
|
||||
progressMessage.textContent = status.message;
|
||||
|
||||
if (status.status === 'completed') {
|
||||
clearInterval(pollInterval);
|
||||
progressArea.classList.add('hidden');
|
||||
resultArea.classList.remove('hidden');
|
||||
document.getElementById('successResult').classList.remove('hidden');
|
||||
submitBtn.textContent = 'Setup starten';
|
||||
submitBtn.disabled = false;
|
||||
form.reset();
|
||||
postTypesContainer.innerHTML = '';
|
||||
postTypeIndex = 0;
|
||||
} else if (status.status === 'error') {
|
||||
clearInterval(pollInterval);
|
||||
progressArea.classList.add('hidden');
|
||||
resultArea.classList.remove('hidden');
|
||||
document.getElementById('errorResult').classList.remove('hidden');
|
||||
document.getElementById('errorMessage').textContent = status.message;
|
||||
submitBtn.textContent = 'Setup starten';
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
progressArea.classList.add('hidden');
|
||||
resultArea.classList.remove('hidden');
|
||||
document.getElementById('errorResult').classList.remove('hidden');
|
||||
document.getElementById('errorMessage').textContent = error.message;
|
||||
submitBtn.textContent = 'Setup starten';
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
1481
src/web/templates/post_detail.html
Normal file
1481
src/web/templates/post_detail.html
Normal file
File diff suppressed because it is too large
Load Diff
152
src/web/templates/posts.html
Normal file
152
src/web/templates/posts.html
Normal file
@@ -0,0 +1,152 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Alle Posts - LinkedIn Posts{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<style>
|
||||
.post-card {
|
||||
background: linear-gradient(135deg, rgba(61, 72, 72, 0.3) 0%, rgba(45, 56, 56, 0.4) 100%);
|
||||
border: 1px solid rgba(61, 72, 72, 0.6);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.post-card:hover {
|
||||
border-color: rgba(255, 199, 0, 0.3);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.customer-header {
|
||||
background: linear-gradient(90deg, rgba(255, 199, 0, 0.1) 0%, transparent 100%);
|
||||
}
|
||||
.score-ring {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.score-high { background: rgba(34, 197, 94, 0.2); border: 2px solid rgba(34, 197, 94, 0.5); color: #86efac; }
|
||||
.score-medium { background: rgba(234, 179, 8, 0.2); border: 2px solid rgba(234, 179, 8, 0.5); color: #fde047; }
|
||||
.score-low { background: rgba(239, 68, 68, 0.2); border: 2px solid rgba(239, 68, 68, 0.5); color: #fca5a5; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Alle Posts</h1>
|
||||
<p class="text-gray-400">{{ total_posts }} generierte Posts</p>
|
||||
</div>
|
||||
<a href="/create" class="px-4 py-2.5 btn-primary rounded-lg font-medium flex items-center gap-2">
|
||||
<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="M12 6v6m0 0v6m0-6h6m-6 0H6"/></svg>
|
||||
Neuer Post
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="bg-red-900/50 border border-red-500 text-red-200 px-4 py-3 rounded-lg mb-6">
|
||||
<strong>Error:</strong> {{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if customers_with_posts %}
|
||||
<div class="space-y-8">
|
||||
{% for item in customers_with_posts %}
|
||||
{% if item.posts %}
|
||||
<div class="card-bg rounded-xl border overflow-hidden">
|
||||
<!-- Customer Header -->
|
||||
<div class="customer-header px-6 py-4 border-b border-brand-bg-light">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 rounded-xl flex items-center justify-center shadow-lg overflow-hidden {{ 'bg-brand-highlight' if not item.profile_picture else '' }}">
|
||||
{% if item.profile_picture %}
|
||||
<img src="{{ item.profile_picture }}" alt="{{ item.customer.name }}" class="w-full h-full object-cover" loading="lazy" referrerpolicy="no-referrer">
|
||||
{% else %}
|
||||
<span class="text-brand-bg-dark font-bold text-lg">{{ item.customer.name[0] | upper }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-white text-lg">{{ item.customer.name }}</h3>
|
||||
<p class="text-sm text-gray-400">{{ item.customer.company_name or 'Kein Unternehmen' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="px-4 py-1.5 bg-brand-bg rounded-full text-sm text-gray-300 font-medium">
|
||||
{{ item.post_count }} Post{{ 's' if item.post_count != 1 else '' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Posts Grid -->
|
||||
<div class="p-4">
|
||||
<div class="grid gap-3">
|
||||
{% for post in item.posts %}
|
||||
<a href="/posts/{{ post.id }}" class="post-card rounded-xl p-4 block group">
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Score Circle -->
|
||||
{% if post.critic_feedback and post.critic_feedback | length > 0 %}
|
||||
{% set score = post.critic_feedback[-1].overall_score %}
|
||||
<div class="score-ring flex-shrink-0 {{ 'score-high' if score >= 85 else 'score-medium' if score >= 70 else 'score-low' }}">
|
||||
{{ score }}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="score-ring flex-shrink-0 bg-brand-bg-dark border-2 border-brand-bg-light text-gray-500">
|
||||
—
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<h4 class="font-medium text-white group-hover:text-brand-highlight transition-colors truncate">
|
||||
{{ post.topic_title or 'Untitled' }}
|
||||
</h4>
|
||||
<span class="flex-shrink-0 px-2 py-0.5 text-xs rounded font-medium {{ 'bg-green-600/20 text-green-400 border border-green-600/30' if post.status == 'approved' else 'bg-yellow-600/20 text-yellow-400 border border-yellow-600/30' }}">
|
||||
{{ post.status | capitalize }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4 mt-1.5 text-sm text-gray-500">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
|
||||
{{ post.created_at.strftime('%d.%m.%Y') if post.created_at else 'N/A' }}
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<svg class="w-3.5 h-3.5" 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>
|
||||
{{ post.created_at.strftime('%H:%M') if post.created_at else '' }}
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
|
||||
{{ post.iterations }} Iteration{{ 's' if post.iterations != 1 else '' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Arrow -->
|
||||
<svg class="w-5 h-5 text-gray-600 group-hover:text-brand-highlight transition-colors flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card-bg rounded-xl border p-12 text-center">
|
||||
<div class="w-20 h-20 bg-brand-bg rounded-2xl flex items-center justify-center mx-auto mb-6">
|
||||
<svg class="w-10 h-10 text-gray-600" 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"/></svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-white mb-2">Noch keine Posts</h3>
|
||||
<p class="text-gray-400 mb-6 max-w-md mx-auto">Erstelle deinen ersten LinkedIn Post mit KI-Unterstützung.</p>
|
||||
<a href="/create" class="inline-flex items-center gap-2 px-6 py-3 btn-primary font-medium rounded-lg transition-colors">
|
||||
<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="M12 6v6m0 0v6m0-6h6m-6 0H6"/></svg>
|
||||
Post erstellen
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
215
src/web/templates/research.html
Normal file
215
src/web/templates/research.html
Normal file
@@ -0,0 +1,215 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Research Topics - LinkedIn Posts{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Research Topics</h1>
|
||||
<p class="text-gray-400">Recherchiere neue Content-Themen mit Perplexity AI</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<!-- Left: Form -->
|
||||
<div>
|
||||
<form id="researchForm" class="card-bg rounded-xl border p-6">
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Kunde auswählen</label>
|
||||
<select name="customer_id" id="customerSelect" required class="w-full input-bg border rounded-lg px-4 py-3 text-white">
|
||||
<option value="">-- Kunde wählen --</option>
|
||||
{% for customer in customers %}
|
||||
<option value="{{ customer.id }}">{{ customer.name }} - {{ customer.company_name or 'Kein Unternehmen' }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Post Type Selection -->
|
||||
<div id="postTypeArea" class="mb-6 hidden">
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Post-Typ (optional)</label>
|
||||
<div id="postTypeCards" class="grid grid-cols-2 gap-2 mb-2">
|
||||
<!-- Post type cards will be loaded here -->
|
||||
</div>
|
||||
<p class="text-xs text-gray-500">Wähle einen Post-Typ für gezielte Recherche oder lasse leer für allgemeine Recherche.</p>
|
||||
<input type="hidden" name="post_type_id" id="selectedPostTypeId" value="">
|
||||
</div>
|
||||
|
||||
<!-- Progress Area -->
|
||||
<div id="progressArea" class="hidden mb-6">
|
||||
<div class="bg-brand-bg rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span id="progressMessage" class="text-gray-300">Starte Recherche...</span>
|
||||
<span id="progressPercent" class="text-gray-400">0%</span>
|
||||
</div>
|
||||
<div class="w-full bg-brand-bg-dark rounded-full h-2">
|
||||
<div id="progressBar" class="bg-brand-highlight h-2 rounded-full transition-all duration-300" style="width: 0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" id="submitBtn" class="w-full btn-primary font-medium py-3 rounded-lg transition-colors flex items-center justify-center gap-2">
|
||||
<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
|
||||
Research starten
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{% if not customers %}
|
||||
<div class="mt-4 bg-yellow-900/30 border border-yellow-600 rounded-lg p-4">
|
||||
<p class="text-yellow-300">Noch keine Kunden vorhanden. <a href="/customers/new" class="underline">Erstelle zuerst einen Kunden</a>.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Right: Results -->
|
||||
<div>
|
||||
<div id="resultsArea" class="card-bg rounded-xl border p-6">
|
||||
<h3 class="text-lg font-semibold text-white mb-4">Gefundene Topics</h3>
|
||||
<div id="topicsList" class="space-y-4">
|
||||
<p class="text-gray-400">Starte eine Recherche um Topics zu finden...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const form = document.getElementById('researchForm');
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
const progressArea = document.getElementById('progressArea');
|
||||
const progressBar = document.getElementById('progressBar');
|
||||
const progressMessage = document.getElementById('progressMessage');
|
||||
const progressPercent = document.getElementById('progressPercent');
|
||||
const topicsList = document.getElementById('topicsList');
|
||||
const customerSelect = document.getElementById('customerSelect');
|
||||
const postTypeArea = document.getElementById('postTypeArea');
|
||||
const postTypeCards = document.getElementById('postTypeCards');
|
||||
const selectedPostTypeId = document.getElementById('selectedPostTypeId');
|
||||
|
||||
let currentPostTypes = [];
|
||||
|
||||
// Load post types when customer is selected
|
||||
customerSelect.addEventListener('change', async () => {
|
||||
const customerId = customerSelect.value;
|
||||
selectedPostTypeId.value = '';
|
||||
|
||||
if (!customerId) {
|
||||
postTypeArea.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/customers/${customerId}/post-types`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.post_types && data.post_types.length > 0) {
|
||||
currentPostTypes = data.post_types;
|
||||
postTypeArea.classList.remove('hidden');
|
||||
|
||||
postTypeCards.innerHTML = `
|
||||
<button type="button" onclick="selectPostType('')" id="pt_all"
|
||||
class="p-3 rounded-lg border text-left transition-colors bg-brand-highlight/20 border-brand-highlight text-white">
|
||||
<div class="font-medium text-sm">Alle Typen</div>
|
||||
<div class="text-xs text-gray-400 mt-1">Allgemeine Recherche</div>
|
||||
</button>
|
||||
` + data.post_types.map(pt => `
|
||||
<button type="button" onclick="selectPostType('${pt.id}')" id="pt_${pt.id}"
|
||||
class="p-3 rounded-lg border text-left transition-colors bg-brand-bg border-brand-bg-light hover:border-brand-highlight/50 text-white">
|
||||
<div class="font-medium text-sm">${pt.name}</div>
|
||||
<div class="text-xs text-gray-400 mt-1">${pt.analyzed_post_count || 0} Posts analysiert</div>
|
||||
${pt.has_analysis ? '<span class="text-xs text-green-400">Analyse</span>' : ''}
|
||||
</button>
|
||||
`).join('');
|
||||
} else {
|
||||
postTypeArea.classList.add('hidden');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load post types:', error);
|
||||
postTypeArea.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
function selectPostType(typeId) {
|
||||
selectedPostTypeId.value = typeId;
|
||||
|
||||
// Update card styles
|
||||
document.querySelectorAll('[id^="pt_"]').forEach(card => {
|
||||
if (card.id === `pt_${typeId}` || (typeId === '' && card.id === 'pt_all')) {
|
||||
card.className = 'p-3 rounded-lg border text-left transition-colors bg-brand-highlight/20 border-brand-highlight text-white';
|
||||
} else {
|
||||
card.className = 'p-3 rounded-lg border text-left transition-colors bg-brand-bg border-brand-bg-light hover:border-brand-highlight/50 text-white';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const customerId = customerSelect.value;
|
||||
if (!customerId) return;
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<svg class="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg> Recherchiert...';
|
||||
progressArea.classList.remove('hidden');
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('customer_id', customerId);
|
||||
if (selectedPostTypeId.value) {
|
||||
formData.append('post_type_id', selectedPostTypeId.value);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/research', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
const taskId = data.task_id;
|
||||
const pollInterval = setInterval(async () => {
|
||||
const statusResponse = await fetch(`/api/tasks/${taskId}`);
|
||||
const status = await statusResponse.json();
|
||||
|
||||
progressBar.style.width = `${status.progress}%`;
|
||||
progressPercent.textContent = `${status.progress}%`;
|
||||
progressMessage.textContent = status.message;
|
||||
|
||||
if (status.status === 'completed') {
|
||||
clearInterval(pollInterval);
|
||||
progressArea.classList.add('hidden');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = '<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg> Research starten';
|
||||
|
||||
// Display topics
|
||||
if (status.topics && status.topics.length > 0) {
|
||||
topicsList.innerHTML = status.topics.map((topic, i) => `
|
||||
<div class="bg-brand-bg rounded-lg p-4 border border-brand-bg-light">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex-1">
|
||||
<span class="inline-block px-2 py-1 text-xs font-medium bg-brand-highlight/20 text-brand-highlight rounded mb-2">${topic.category || 'Topic'}</span>
|
||||
<h4 class="font-semibold text-white">${topic.title}</h4>
|
||||
${topic.angle ? `<p class="text-sm text-brand-highlight/80 mt-1">↳ ${topic.angle}</p>` : ''}
|
||||
${topic.hook_idea ? `<p class="text-sm text-gray-300 mt-2 italic border-l-2 border-brand-highlight/30 pl-2">"${topic.hook_idea.substring(0, 150)}..."</p>` : ''}
|
||||
<p class="text-gray-400 text-sm mt-2">${topic.fact ? topic.fact.substring(0, 200) + '...' : ''}</p>
|
||||
${topic.source ? `<p class="text-gray-500 text-xs mt-2">Quelle: ${topic.source}</p>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
} else {
|
||||
topicsList.innerHTML = '<p class="text-gray-400">Keine Topics gefunden.</p>';
|
||||
}
|
||||
} else if (status.status === 'error') {
|
||||
clearInterval(pollInterval);
|
||||
progressArea.classList.add('hidden');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = '<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg> Research starten';
|
||||
topicsList.innerHTML = `<p class="text-red-400">Fehler: ${status.message}</p>`;
|
||||
}
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
progressArea.classList.add('hidden');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = '<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg> Research starten';
|
||||
topicsList.innerHTML = `<p class="text-red-400">Fehler: ${error.message}</p>`;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
571
src/web/templates/scraped_posts.html
Normal file
571
src/web/templates/scraped_posts.html
Normal file
@@ -0,0 +1,571 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Gescrapte Posts - LinkedIn Posts{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<style>
|
||||
.post-card {
|
||||
background: linear-gradient(135deg, rgba(61, 72, 72, 0.3) 0%, rgba(45, 56, 56, 0.4) 100%);
|
||||
border: 1px solid rgba(61, 72, 72, 0.6);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.post-card:hover {
|
||||
border-color: rgba(255, 199, 0, 0.3);
|
||||
}
|
||||
.post-card.selected {
|
||||
border-color: rgba(255, 199, 0, 0.6);
|
||||
background: linear-gradient(135deg, rgba(255, 199, 0, 0.05) 0%, rgba(45, 56, 56, 0.4) 100%);
|
||||
}
|
||||
.type-badge {
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.type-badge:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
.type-badge.active {
|
||||
background-color: rgba(255, 199, 0, 0.2);
|
||||
border-color: #ffc700;
|
||||
}
|
||||
.post-content-preview {
|
||||
max-height: 150px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
.post-content-preview::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 40px;
|
||||
background: linear-gradient(transparent, rgba(45, 56, 56, 0.9));
|
||||
}
|
||||
.post-content-expanded {
|
||||
max-height: none;
|
||||
}
|
||||
.post-content-expanded::after {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Gescrapte Posts verwalten</h1>
|
||||
<p class="text-gray-400">Posts manuell kategorisieren und Post-Typ-Analyse triggern</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Customer Selection -->
|
||||
<div class="card-bg rounded-xl border p-6 mb-6">
|
||||
<div class="flex flex-wrap items-end gap-4">
|
||||
<div class="flex-1 min-w-64">
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Kunde auswählen</label>
|
||||
<select id="customerSelect" class="w-full input-bg border rounded-lg px-4 py-3 text-white">
|
||||
<option value="">-- Kunde wählen --</option>
|
||||
{% for customer in customers %}
|
||||
<option value="{{ customer.id }}">{{ customer.name }} - {{ customer.company_name or 'Kein Unternehmen' }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button id="classifyAllBtn" class="hidden px-4 py-3 bg-brand-bg hover:bg-brand-bg-light border border-brand-bg-light rounded-lg text-white transition-colors">
|
||||
<span class="flex items-center gap-2">
|
||||
<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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
|
||||
Auto-Klassifizieren
|
||||
</span>
|
||||
</button>
|
||||
<button id="analyzeTypesBtn" class="hidden px-4 py-3 btn-primary rounded-lg font-medium transition-colors">
|
||||
<span class="flex items-center gap-2">
|
||||
<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 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>
|
||||
Post-Typen analysieren
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress Area -->
|
||||
<div id="progressArea" class="hidden card-bg rounded-xl border p-6 mb-6">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span id="progressMessage" class="text-gray-300">Arbeite...</span>
|
||||
<span id="progressPercent" class="text-gray-400">0%</span>
|
||||
</div>
|
||||
<div class="w-full bg-brand-bg-dark rounded-full h-2">
|
||||
<div id="progressBar" class="bg-brand-highlight h-2 rounded-full transition-all duration-300" style="width: 0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats & Post Types -->
|
||||
<div id="statsArea" class="hidden mb-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4">
|
||||
<div class="card-bg rounded-xl border p-4">
|
||||
<div class="text-2xl font-bold text-white" id="totalPostsCount">0</div>
|
||||
<div class="text-sm text-gray-400">Gesamt Posts</div>
|
||||
</div>
|
||||
<div class="card-bg rounded-xl border p-4">
|
||||
<div class="text-2xl font-bold text-green-400" id="classifiedCount">0</div>
|
||||
<div class="text-sm text-gray-400">Klassifiziert</div>
|
||||
</div>
|
||||
<div class="card-bg rounded-xl border p-4">
|
||||
<div class="text-2xl font-bold text-yellow-400" id="unclassifiedCount">0</div>
|
||||
<div class="text-sm text-gray-400">Nicht klassifiziert</div>
|
||||
</div>
|
||||
<div class="card-bg rounded-xl border p-4">
|
||||
<div class="text-2xl font-bold text-brand-highlight" id="postTypesCount">0</div>
|
||||
<div class="text-sm text-gray-400">Post-Typen</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Post Type Filter -->
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
<button onclick="filterByType(null)" id="filter_all" class="type-badge px-3 py-2 rounded-lg border text-sm transition-colors bg-brand-highlight/20 border-brand-highlight text-white">
|
||||
Alle
|
||||
</button>
|
||||
<button onclick="filterByType('unclassified')" id="filter_unclassified" class="type-badge px-3 py-2 rounded-lg border text-sm transition-colors bg-brand-bg border-brand-bg-light hover:border-brand-highlight/50 text-white">
|
||||
Nicht klassifiziert
|
||||
</button>
|
||||
<div id="postTypeFilters" class="contents">
|
||||
<!-- Post type filter buttons will be added here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Posts List -->
|
||||
<div id="postsArea" class="hidden">
|
||||
<div id="postsList" class="space-y-4">
|
||||
<p class="text-gray-400">Lade Posts...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div id="emptyState" class="hidden card-bg rounded-xl border p-12 text-center">
|
||||
<div class="w-20 h-20 bg-brand-bg rounded-2xl flex items-center justify-center mx-auto mb-6">
|
||||
<svg class="w-10 h-10 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/></svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-white mb-2">Keine gescrapten Posts</h3>
|
||||
<p class="text-gray-400 mb-6 max-w-md mx-auto">Für diesen Kunden wurden noch keine LinkedIn Posts gescrapet.</p>
|
||||
</div>
|
||||
|
||||
{% if not customers %}
|
||||
<div class="bg-yellow-900/30 border border-yellow-600 rounded-lg p-4">
|
||||
<p class="text-yellow-300">Noch keine Kunden vorhanden. <a href="/customers/new" class="underline">Erstelle zuerst einen Kunden</a>.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Post Detail Modal -->
|
||||
<div id="postModal" class="fixed inset-0 bg-black/70 hidden items-center justify-center z-50 p-4">
|
||||
<div class="bg-brand-bg-dark rounded-xl border border-brand-bg-light max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col shadow-2xl">
|
||||
<div class="p-4 border-b border-brand-bg-light flex items-center justify-between bg-brand-bg">
|
||||
<h3 class="text-lg font-semibold text-white">Post Details</h3>
|
||||
<button onclick="closeModal()" class="text-gray-400 hover:text-white p-1 hover:bg-brand-bg-light rounded">
|
||||
<svg class="w-6 h-6" 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>
|
||||
</div>
|
||||
<div class="p-6 overflow-y-auto flex-1">
|
||||
<div id="modalContent"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const customerSelect = document.getElementById('customerSelect');
|
||||
const classifyAllBtn = document.getElementById('classifyAllBtn');
|
||||
const analyzeTypesBtn = document.getElementById('analyzeTypesBtn');
|
||||
const progressArea = document.getElementById('progressArea');
|
||||
const progressBar = document.getElementById('progressBar');
|
||||
const progressMessage = document.getElementById('progressMessage');
|
||||
const progressPercent = document.getElementById('progressPercent');
|
||||
const statsArea = document.getElementById('statsArea');
|
||||
const postsArea = document.getElementById('postsArea');
|
||||
const postsList = document.getElementById('postsList');
|
||||
const emptyState = document.getElementById('emptyState');
|
||||
const postTypeFilters = document.getElementById('postTypeFilters');
|
||||
const postModal = document.getElementById('postModal');
|
||||
const modalContent = document.getElementById('modalContent');
|
||||
|
||||
let currentPosts = [];
|
||||
let currentPostTypes = [];
|
||||
let currentFilter = null;
|
||||
|
||||
customerSelect.addEventListener('change', async () => {
|
||||
const customerId = customerSelect.value;
|
||||
|
||||
if (!customerId) {
|
||||
statsArea.classList.add('hidden');
|
||||
postsArea.classList.add('hidden');
|
||||
emptyState.classList.add('hidden');
|
||||
classifyAllBtn.classList.add('hidden');
|
||||
analyzeTypesBtn.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
await loadCustomerData(customerId);
|
||||
});
|
||||
|
||||
async function loadCustomerData(customerId) {
|
||||
// Load post types
|
||||
try {
|
||||
const ptResponse = await fetch(`/api/customers/${customerId}/post-types`);
|
||||
const ptData = await ptResponse.json();
|
||||
currentPostTypes = ptData.post_types || [];
|
||||
|
||||
// Update post type filters
|
||||
postTypeFilters.innerHTML = currentPostTypes.map(pt => `
|
||||
<button onclick="filterByType('${pt.id}')" id="filter_${pt.id}"
|
||||
class="type-badge px-3 py-2 rounded-lg border text-sm transition-colors bg-brand-bg border-brand-bg-light hover:border-brand-highlight/50 text-white">
|
||||
${escapeHtml(pt.name)}
|
||||
<span class="ml-1 text-xs text-gray-400">(${pt.analyzed_post_count || 0})</span>
|
||||
${pt.has_analysis ? '<span class="ml-1 text-green-400">*</span>' : ''}
|
||||
</button>
|
||||
`).join('');
|
||||
|
||||
document.getElementById('postTypesCount').textContent = currentPostTypes.length;
|
||||
} catch (error) {
|
||||
console.error('Failed to load post types:', error);
|
||||
}
|
||||
|
||||
// Load posts
|
||||
try {
|
||||
const response = await fetch(`/api/customers/${customerId}/linkedin-posts`);
|
||||
const data = await response.json();
|
||||
|
||||
console.log('API Response:', data);
|
||||
|
||||
if (data.error) {
|
||||
console.error('API Error:', data.error);
|
||||
postsList.innerHTML = `<p class="text-red-400">API Fehler: ${escapeHtml(data.error)}</p>`;
|
||||
postsArea.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
currentPosts = data.posts || [];
|
||||
console.log(`Loaded ${currentPosts.length} posts`);
|
||||
|
||||
if (currentPosts.length === 0) {
|
||||
statsArea.classList.add('hidden');
|
||||
postsArea.classList.add('hidden');
|
||||
emptyState.classList.remove('hidden');
|
||||
classifyAllBtn.classList.add('hidden');
|
||||
analyzeTypesBtn.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
// Update stats
|
||||
const classified = currentPosts.filter(p => p.post_type_id).length;
|
||||
const unclassified = currentPosts.length - classified;
|
||||
|
||||
document.getElementById('totalPostsCount').textContent = currentPosts.length;
|
||||
document.getElementById('classifiedCount').textContent = classified;
|
||||
document.getElementById('unclassifiedCount').textContent = unclassified;
|
||||
|
||||
statsArea.classList.remove('hidden');
|
||||
postsArea.classList.remove('hidden');
|
||||
emptyState.classList.add('hidden');
|
||||
classifyAllBtn.classList.remove('hidden');
|
||||
analyzeTypesBtn.classList.remove('hidden');
|
||||
|
||||
currentFilter = null;
|
||||
filterByType(null);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load posts:', error);
|
||||
postsList.innerHTML = `<p class="text-red-400">Fehler beim Laden: ${error.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function filterByType(typeId) {
|
||||
currentFilter = typeId;
|
||||
|
||||
// Update filter button styles
|
||||
document.querySelectorAll('.type-badge').forEach(btn => {
|
||||
const btnId = btn.id.replace('filter_', '');
|
||||
const isActive = (typeId === null && btnId === 'all') ||
|
||||
(typeId === 'unclassified' && btnId === 'unclassified') ||
|
||||
(btnId === typeId);
|
||||
|
||||
if (isActive) {
|
||||
btn.classList.add('active', 'bg-brand-highlight/20', 'border-brand-highlight');
|
||||
btn.classList.remove('bg-brand-bg', 'border-brand-bg-light');
|
||||
} else {
|
||||
btn.classList.remove('active', 'bg-brand-highlight/20', 'border-brand-highlight');
|
||||
btn.classList.add('bg-brand-bg', 'border-brand-bg-light');
|
||||
}
|
||||
});
|
||||
|
||||
// Filter posts
|
||||
let filteredPosts = currentPosts;
|
||||
if (typeId === 'unclassified') {
|
||||
filteredPosts = currentPosts.filter(p => !p.post_type_id);
|
||||
} else if (typeId) {
|
||||
filteredPosts = currentPosts.filter(p => p.post_type_id === typeId);
|
||||
}
|
||||
|
||||
renderPosts(filteredPosts);
|
||||
}
|
||||
|
||||
function renderPosts(posts) {
|
||||
if (posts.length === 0) {
|
||||
postsList.innerHTML = '<p class="text-gray-400 text-center py-8">Keine Posts in dieser Kategorie.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
postsList.innerHTML = posts.map((post, index) => {
|
||||
const postType = currentPostTypes.find(pt => pt.id === post.post_type_id);
|
||||
const postText = post.post_text || '';
|
||||
const previewText = postText.substring(0, 300);
|
||||
|
||||
return `
|
||||
<div class="post-card rounded-xl p-4 cursor-pointer" data-post-id="${post.id}" onclick="openPostModal('${post.id}')">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-2 mb-3 flex-wrap">
|
||||
${postType ? `
|
||||
<span class="px-2 py-1 text-xs font-medium bg-brand-highlight/20 text-brand-highlight rounded">
|
||||
${escapeHtml(postType.name)}
|
||||
</span>
|
||||
<span class="text-xs text-gray-500">${post.classification_method || 'unknown'} (${Math.round((post.classification_confidence || 0) * 100)}%)</span>
|
||||
` : `
|
||||
<span class="px-2 py-1 text-xs font-medium bg-gray-600/30 text-gray-400 rounded">
|
||||
Nicht klassifiziert
|
||||
</span>
|
||||
`}
|
||||
${post.posted_at ? `
|
||||
<span class="text-xs text-gray-500">${new Date(post.posted_at).toLocaleDateString('de-DE')}</span>
|
||||
` : ''}
|
||||
${post.engagement_score ? `
|
||||
<span class="text-xs text-gray-500">Engagement: ${post.engagement_score}</span>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
<!-- Content Preview -->
|
||||
<div class="post-content-preview text-gray-300 text-sm whitespace-pre-wrap mb-3">
|
||||
${escapeHtml(previewText)}${postText.length > 300 ? '...' : ''}
|
||||
</div>
|
||||
|
||||
<!-- Click hint & Type badges -->
|
||||
<div class="flex items-center justify-between flex-wrap gap-2" onclick="event.stopPropagation()">
|
||||
<span class="text-xs text-gray-500 italic">Klicken für Vollansicht</span>
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
${currentPostTypes.map(pt => `
|
||||
<button onclick="event.stopPropagation(); classifyPost('${post.id}', '${pt.id}')"
|
||||
class="px-2 py-1 text-xs rounded transition-colors ${post.post_type_id === pt.id ? 'bg-brand-highlight/30 text-brand-highlight border border-brand-highlight' : 'bg-brand-bg hover:bg-brand-bg-light text-gray-300 border border-brand-bg-light'}">
|
||||
${escapeHtml(pt.name)}
|
||||
</button>
|
||||
`).join('')}
|
||||
${post.post_type_id ? `
|
||||
<button onclick="event.stopPropagation(); classifyPost('${post.id}', null)" class="px-2 py-1 text-xs rounded bg-red-900/30 hover:bg-red-900/50 text-red-300 border border-red-900/50">
|
||||
✕
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function classifyPost(postId, postTypeId) {
|
||||
const customerId = customerSelect.value;
|
||||
if (!customerId) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/linkedin-posts/${postId}/classify`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ post_type_id: postTypeId })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to classify post');
|
||||
}
|
||||
|
||||
// Update local data
|
||||
const post = currentPosts.find(p => p.id === postId);
|
||||
if (post) {
|
||||
post.post_type_id = postTypeId;
|
||||
post.classification_method = 'manual';
|
||||
post.classification_confidence = 1.0;
|
||||
}
|
||||
|
||||
// Update stats
|
||||
const classified = currentPosts.filter(p => p.post_type_id).length;
|
||||
document.getElementById('classifiedCount').textContent = classified;
|
||||
document.getElementById('unclassifiedCount').textContent = currentPosts.length - classified;
|
||||
|
||||
// Re-render
|
||||
filterByType(currentFilter);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to classify post:', error);
|
||||
alert('Fehler beim Klassifizieren: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function openPostModal(postId) {
|
||||
const post = currentPosts.find(p => p.id === postId);
|
||||
if (!post) return;
|
||||
|
||||
const postType = currentPostTypes.find(pt => pt.id === post.post_type_id);
|
||||
|
||||
modalContent.innerHTML = `
|
||||
<div class="mb-4 flex items-center gap-3 flex-wrap">
|
||||
${postType ? `
|
||||
<span class="px-3 py-1.5 text-sm font-medium bg-brand-highlight/20 text-brand-highlight rounded-lg">
|
||||
${escapeHtml(postType.name)}
|
||||
</span>
|
||||
` : `
|
||||
<span class="px-3 py-1.5 text-sm font-medium bg-gray-600/30 text-gray-400 rounded-lg">
|
||||
Nicht klassifiziert
|
||||
</span>
|
||||
`}
|
||||
${post.posted_at ? `
|
||||
<span class="text-sm text-gray-500">${new Date(post.posted_at).toLocaleDateString('de-DE')}</span>
|
||||
` : ''}
|
||||
${post.engagement_score ? `
|
||||
<span class="text-sm text-gray-500">Engagement: ${post.engagement_score}</span>
|
||||
` : ''}
|
||||
</div>
|
||||
<div class="bg-brand-bg rounded-xl p-6 mb-6 border border-brand-bg-light max-h-[50vh] overflow-y-auto">
|
||||
<div class="whitespace-pre-wrap text-gray-200 font-sans text-base leading-relaxed">${escapeHtml(post.post_text || '')}</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 flex-wrap border-t border-brand-bg-light pt-4">
|
||||
<span class="text-sm text-gray-400 font-medium">Typ zuweisen:</span>
|
||||
${currentPostTypes.map(pt => `
|
||||
<button onclick="classifyPost('${post.id}', '${pt.id}'); closeModal();"
|
||||
class="px-4 py-2 text-sm rounded-lg transition-colors ${post.post_type_id === pt.id ? 'bg-brand-highlight text-brand-bg-dark font-medium' : 'bg-brand-bg hover:bg-brand-bg-light text-gray-300 border border-brand-bg-light'}">
|
||||
${escapeHtml(pt.name)}
|
||||
</button>
|
||||
`).join('')}
|
||||
${post.post_type_id ? `
|
||||
<button onclick="classifyPost('${post.id}', null); closeModal();" class="px-4 py-2 text-sm rounded-lg bg-red-900/30 hover:bg-red-900/50 text-red-300 border border-red-900/50">
|
||||
Klassifizierung entfernen
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
postModal.classList.remove('hidden');
|
||||
postModal.classList.add('flex');
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
postModal.classList.add('hidden');
|
||||
postModal.classList.remove('flex');
|
||||
}
|
||||
|
||||
// Close modal on backdrop click
|
||||
postModal.addEventListener('click', (e) => {
|
||||
if (e.target === postModal) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Close modal on Escape key
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && !postModal.classList.contains('hidden')) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-classify button
|
||||
classifyAllBtn.addEventListener('click', async () => {
|
||||
const customerId = customerSelect.value;
|
||||
if (!customerId) return;
|
||||
|
||||
classifyAllBtn.disabled = true;
|
||||
progressArea.classList.remove('hidden');
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/customers/${customerId}/classify-posts`, {
|
||||
method: 'POST'
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
const taskId = data.task_id;
|
||||
await pollTask(taskId, async () => {
|
||||
await loadCustomerData(customerId);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Classification failed:', error);
|
||||
alert('Fehler bei der Klassifizierung: ' + error.message);
|
||||
} finally {
|
||||
classifyAllBtn.disabled = false;
|
||||
progressArea.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// Analyze post types button
|
||||
analyzeTypesBtn.addEventListener('click', async () => {
|
||||
const customerId = customerSelect.value;
|
||||
if (!customerId) return;
|
||||
|
||||
analyzeTypesBtn.disabled = true;
|
||||
progressArea.classList.remove('hidden');
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/customers/${customerId}/analyze-post-types`, {
|
||||
method: 'POST'
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
const taskId = data.task_id;
|
||||
await pollTask(taskId, async () => {
|
||||
await loadCustomerData(customerId);
|
||||
alert('Post-Typ-Analyse abgeschlossen! Die Analysen wurden aktualisiert.');
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Analysis failed:', error);
|
||||
alert('Fehler bei der Analyse: ' + error.message);
|
||||
} finally {
|
||||
analyzeTypesBtn.disabled = false;
|
||||
progressArea.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
async function pollTask(taskId, onComplete) {
|
||||
return new Promise((resolve) => {
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const statusResponse = await fetch(`/api/tasks/${taskId}`);
|
||||
const status = await statusResponse.json();
|
||||
|
||||
progressBar.style.width = `${status.progress}%`;
|
||||
progressPercent.textContent = `${status.progress}%`;
|
||||
progressMessage.textContent = status.message;
|
||||
|
||||
if (status.status === 'completed') {
|
||||
clearInterval(interval);
|
||||
await onComplete();
|
||||
resolve();
|
||||
} else if (status.status === 'error') {
|
||||
clearInterval(interval);
|
||||
alert('Fehler: ' + status.message);
|
||||
resolve();
|
||||
}
|
||||
} catch (error) {
|
||||
clearInterval(interval);
|
||||
console.error('Polling error:', error);
|
||||
resolve();
|
||||
}
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
155
src/web/templates/status.html
Normal file
155
src/web/templates/status.html
Normal file
@@ -0,0 +1,155 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Status - LinkedIn Posts{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Status</h1>
|
||||
<p class="text-gray-400">Übersicht über alle Kunden und deren Setup-Status</p>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="bg-red-900/50 border border-red-500 text-red-200 px-4 py-3 rounded-lg mb-6">
|
||||
<strong>Error:</strong> {{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if customer_statuses %}
|
||||
<div class="grid gap-6">
|
||||
{% for item in customer_statuses %}
|
||||
<div class="card-bg rounded-xl border overflow-hidden">
|
||||
<!-- Customer Header -->
|
||||
<div class="px-6 py-4 border-b border-brand-bg-light">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 rounded-full flex items-center justify-center overflow-hidden {{ 'bg-brand-highlight' if not item.profile_picture else '' }}">
|
||||
{% if item.profile_picture %}
|
||||
<img src="{{ item.profile_picture }}" alt="{{ item.customer.name }}" class="w-full h-full object-cover" loading="lazy" referrerpolicy="no-referrer">
|
||||
{% else %}
|
||||
<span class="text-brand-bg-dark font-bold text-lg">{{ item.customer.name[0] | upper }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-white text-lg">{{ item.customer.name }}</h3>
|
||||
<p class="text-sm text-gray-400">{{ item.customer.company_name or 'Kein Unternehmen' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
{% if item.status.ready_for_posts %}
|
||||
<span class="px-3 py-1.5 bg-green-600/30 text-green-300 rounded-lg text-sm font-medium flex items-center gap-2">
|
||||
<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="M5 13l4 4L19 7"/></svg>
|
||||
Bereit für Posts
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="px-3 py-1.5 bg-yellow-600/30 text-yellow-300 rounded-lg text-sm font-medium flex items-center gap-2">
|
||||
<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="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>
|
||||
Setup unvollständig
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Grid -->
|
||||
<div class="p-6">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<!-- Scraped Posts -->
|
||||
<div class="bg-brand-bg/30 rounded-lg p-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
{% if item.status.has_scraped_posts %}
|
||||
<svg class="w-5 h-5 text-green-500" 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>
|
||||
{% else %}
|
||||
<svg class="w-5 h-5 text-gray-500" 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>
|
||||
{% endif %}
|
||||
<span class="text-sm text-gray-400">Scraped Posts</span>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-white">{{ item.status.scraped_posts_count }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Profile Analysis -->
|
||||
<div class="bg-brand-bg/30 rounded-lg p-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
{% if item.status.has_profile_analysis %}
|
||||
<svg class="w-5 h-5 text-green-500" 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>
|
||||
{% else %}
|
||||
<svg class="w-5 h-5 text-gray-500" 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>
|
||||
{% endif %}
|
||||
<span class="text-sm text-gray-400">Profil Analyse</span>
|
||||
</div>
|
||||
<p class="text-lg font-semibold text-white">{{ 'Vorhanden' if item.status.has_profile_analysis else 'Fehlt' }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Research Topics -->
|
||||
<div class="bg-brand-bg/30 rounded-lg p-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
{% if item.status.research_count > 0 %}
|
||||
<svg class="w-5 h-5 text-green-500" 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>
|
||||
{% else %}
|
||||
<svg class="w-5 h-5 text-gray-500" 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>
|
||||
{% endif %}
|
||||
<span class="text-sm text-gray-400">Research Topics</span>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-white">{{ item.status.research_count }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Generated Posts -->
|
||||
<div class="bg-brand-bg/30 rounded-lg p-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<svg class="w-5 h-5 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 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>
|
||||
<span class="text-sm text-gray-400">Generierte Posts</span>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-white">{{ item.status.posts_count }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Missing Items -->
|
||||
{% if item.status.missing_items %}
|
||||
<div class="bg-yellow-900/20 border border-yellow-600/50 rounded-lg p-4">
|
||||
<h4 class="font-medium text-yellow-300 mb-2 flex items-center gap-2">
|
||||
<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="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>
|
||||
Fehlende Elemente
|
||||
</h4>
|
||||
<ul class="space-y-1">
|
||||
{% for item_missing in item.status.missing_items %}
|
||||
<li class="text-yellow-200/80 text-sm flex items-center gap-2">
|
||||
<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="M9 5l7 7-7 7"/></svg>
|
||||
{{ item_missing }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="flex gap-3 mt-4">
|
||||
{% if not item.status.has_profile_analysis %}
|
||||
<a href="/customers/new" class="px-4 py-2 btn-primary rounded-lg text-sm transition-colors">
|
||||
Setup wiederholen
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if item.status.research_count == 0 %}
|
||||
<a href="/research" class="px-4 py-2 bg-green-600 hover:bg-green-700 rounded-lg text-sm text-white transition-colors">
|
||||
Recherche starten
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if item.status.ready_for_posts %}
|
||||
<a href="/create" class="px-4 py-2 btn-primary rounded-lg text-sm transition-colors">
|
||||
Post erstellen
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card-bg rounded-xl border p-12 text-center">
|
||||
<svg class="w-16 h-16 text-gray-600 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
|
||||
<h3 class="text-xl font-semibold text-white mb-2">Noch keine Kunden</h3>
|
||||
<p class="text-gray-400 mb-6">Erstelle deinen ersten Kunden, um den Status zu sehen.</p>
|
||||
<a href="/customers/new" class="inline-flex items-center gap-2 px-6 py-3 btn-primary font-medium rounded-lg transition-colors">
|
||||
<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="M12 6v6m0 0v6m0-6h6m-6 0H6"/></svg>
|
||||
Neuer Kunde
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
45
src/web/templates/user/auth_callback.html
Normal file
45
src/web/templates/user/auth_callback.html
Normal file
@@ -0,0 +1,45 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Anmeldung... - LinkedIn Posts</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
body { background-color: #3d4848; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="text-gray-100 min-h-screen flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<div class="w-16 h-16 border-4 border-brand-highlight border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
|
||||
<p class="text-white text-lg">Anmeldung wird verarbeitet...</p>
|
||||
<p class="text-gray-400 text-sm mt-2">Du wirst gleich weitergeleitet.</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Supabase returns tokens in URL hash fragment
|
||||
// Extract them and redirect to callback with query params
|
||||
const hash = window.location.hash.substring(1);
|
||||
const params = new URLSearchParams(hash);
|
||||
|
||||
const accessToken = params.get('access_token');
|
||||
const refreshToken = params.get('refresh_token');
|
||||
const error = params.get('error');
|
||||
const errorDescription = params.get('error_description');
|
||||
|
||||
if (error) {
|
||||
window.location.href = `/login?error=${encodeURIComponent(error)}`;
|
||||
} else if (accessToken) {
|
||||
// Redirect back to callback with tokens in query params
|
||||
let callbackUrl = `/auth/callback?access_token=${encodeURIComponent(accessToken)}`;
|
||||
if (refreshToken) {
|
||||
callbackUrl += `&refresh_token=${encodeURIComponent(refreshToken)}`;
|
||||
}
|
||||
window.location.href = callbackUrl;
|
||||
} else {
|
||||
// No tokens found, redirect to login
|
||||
window.location.href = '/login?error=no_tokens';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
113
src/web/templates/user/base.html
Normal file
113
src/web/templates/user/base.html
Normal file
@@ -0,0 +1,113 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}LinkedIn Posts{% endblock %}</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
'brand': {
|
||||
'bg': '#3d4848',
|
||||
'bg-light': '#4a5858',
|
||||
'bg-dark': '#2d3838',
|
||||
'highlight': '#ffc700',
|
||||
'highlight-dark': '#e6b300',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
body { background-color: #3d4848; }
|
||||
.nav-link.active { background-color: #ffc700; color: #2d3838; }
|
||||
.nav-link.active svg { stroke: #2d3838; }
|
||||
.post-content { white-space: pre-wrap; word-wrap: break-word; }
|
||||
.btn-primary { background-color: #ffc700; color: #2d3838; }
|
||||
.btn-primary:hover { background-color: #e6b300; }
|
||||
.sidebar-bg { background-color: #2d3838; }
|
||||
.card-bg { background-color: #4a5858; border-color: #5a6868; }
|
||||
.input-bg { background-color: #3d4848; border-color: #5a6868; }
|
||||
.input-bg:focus { border-color: #ffc700; outline: none; }
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-track { background: #3d4848; }
|
||||
::-webkit-scrollbar-thumb { background: #5a6868; border-radius: 4px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #6a7878; }
|
||||
</style>
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body class="text-gray-100 min-h-screen flex">
|
||||
<!-- Sidebar -->
|
||||
<aside class="w-64 sidebar-bg border-r border-gray-600 flex flex-col fixed h-full">
|
||||
<div class="p-4 border-b border-gray-600">
|
||||
<div class="flex items-center justify-center gap-3">
|
||||
<div>
|
||||
<img src="/static/logo.png" alt="Logo" class="h-15 w-auto">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Profile -->
|
||||
{% if session %}
|
||||
<div class="p-4 border-b border-gray-600">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-full overflow-hidden bg-brand-highlight flex items-center justify-center">
|
||||
{% if session.linkedin_picture %}
|
||||
<img src="{{ session.linkedin_picture }}" alt="{{ session.linkedin_name }}" class="w-full h-full object-cover" referrerpolicy="no-referrer">
|
||||
{% else %}
|
||||
<span class="text-brand-bg-dark font-bold">{{ session.customer_name[0] | upper }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-white font-medium text-sm truncate">{{ session.linkedin_name or session.customer_name }}</p>
|
||||
<p class="text-gray-400 text-xs truncate">{{ session.customer_name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<nav class="flex-1 p-4 space-y-2">
|
||||
<a href="/" 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 == 'home' %}active{% endif %}">
|
||||
<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="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/></svg>
|
||||
Dashboard
|
||||
</a>
|
||||
<a href="/research" 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 == 'research' %}active{% endif %}">
|
||||
<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
|
||||
Research Topics
|
||||
</a>
|
||||
<a href="/create" 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 == 'create' %}active{% endif %}">
|
||||
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
||||
Post erstellen
|
||||
</a>
|
||||
<a href="/posts" 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 == 'posts' %}active{% endif %}">
|
||||
<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="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/></svg>
|
||||
Meine Posts
|
||||
</a>
|
||||
<a href="/status" 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 == 'status' %}active{% endif %}">
|
||||
<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 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>
|
||||
Status
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div class="p-4 border-t border-gray-600">
|
||||
<a href="/logout" class="flex items-center gap-2 text-gray-400 hover:text-gray-200 text-sm transition-colors">
|
||||
<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="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/></svg>
|
||||
Logout
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="flex-1 ml-64">
|
||||
<div class="p-8">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
479
src/web/templates/user/create_post.html
Normal file
479
src/web/templates/user/create_post.html
Normal file
@@ -0,0 +1,479 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Post erstellen - LinkedIn Posts{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Post erstellen</h1>
|
||||
<p class="text-gray-400">Generiere einen neuen LinkedIn Post mit AI</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<!-- Left: Form -->
|
||||
<div>
|
||||
<form id="createPostForm" class="card-bg rounded-xl border p-6 space-y-6">
|
||||
<!-- Post Type Selection -->
|
||||
<div id="postTypeSelectionArea" class="hidden">
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Post-Typ auswählen (optional)</label>
|
||||
<div id="postTypeCards" class="flex flex-wrap gap-2 mb-2">
|
||||
<!-- Post type cards will be loaded here -->
|
||||
</div>
|
||||
<input type="hidden" id="selectedPostTypeId" value="">
|
||||
</div>
|
||||
|
||||
<!-- Topic Selection -->
|
||||
<div id="topicSelectionArea">
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Topic auswählen</label>
|
||||
<div id="topicsList" class="space-y-2 max-h-64 overflow-y-auto">
|
||||
<p class="text-gray-500">Lade Topics...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Topic -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">
|
||||
<span>Oder eigenes Topic eingeben</span>
|
||||
</label>
|
||||
<div class="space-y-3">
|
||||
<input type="text" id="customTopicTitle" placeholder="Topic Titel" class="w-full input-bg border rounded-lg px-4 py-2 text-white">
|
||||
<textarea id="customTopicFact" rows="3" placeholder="Fakt / Kernaussage zum Topic..." class="w-full input-bg border rounded-lg px-4 py-2 text-white"></textarea>
|
||||
<input type="text" id="customTopicSource" placeholder="Quelle (optional)" class="w-full input-bg border rounded-lg px-4 py-2 text-white">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress Area -->
|
||||
<div id="progressArea" class="hidden">
|
||||
<div class="bg-brand-bg rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span id="progressMessage" class="text-gray-300">Starte Post-Erstellung...</span>
|
||||
<span id="progressPercent" class="text-gray-400">0%</span>
|
||||
</div>
|
||||
<div class="w-full bg-brand-bg-dark rounded-full h-2">
|
||||
<div id="progressBar" class="bg-brand-highlight h-2 rounded-full transition-all duration-300" style="width: 0%"></div>
|
||||
</div>
|
||||
<div id="iterationInfo" class="mt-2 text-sm text-gray-400"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" id="submitBtn" class="w-full btn-primary font-medium py-3 rounded-lg transition-colors flex items-center justify-center gap-2">
|
||||
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
||||
Post generieren
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Right: Result -->
|
||||
<div>
|
||||
<div id="resultArea" class="card-bg rounded-xl border p-6">
|
||||
<h3 class="text-lg font-semibold text-white mb-4">Generierter Post</h3>
|
||||
|
||||
<!-- Live Versions Display -->
|
||||
<div id="liveVersions" class="hidden space-y-4 mb-6">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="text-sm text-gray-400">Live-Vorschau der Iterationen:</span>
|
||||
</div>
|
||||
<div id="versionsContainer" class="space-y-4"></div>
|
||||
</div>
|
||||
|
||||
<div id="postResult">
|
||||
<p class="text-gray-400">Wähle ein Topic aus oder gib ein eigenes ein, dann klicke auf "Post generieren"...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const form = document.getElementById('createPostForm');
|
||||
const topicSelectionArea = document.getElementById('topicSelectionArea');
|
||||
const topicsList = document.getElementById('topicsList');
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
const progressArea = document.getElementById('progressArea');
|
||||
const progressBar = document.getElementById('progressBar');
|
||||
const progressMessage = document.getElementById('progressMessage');
|
||||
const progressPercent = document.getElementById('progressPercent');
|
||||
const iterationInfo = document.getElementById('iterationInfo');
|
||||
const postResult = document.getElementById('postResult');
|
||||
const liveVersions = document.getElementById('liveVersions');
|
||||
const versionsContainer = document.getElementById('versionsContainer');
|
||||
const postTypeSelectionArea = document.getElementById('postTypeSelectionArea');
|
||||
const postTypeCards = document.getElementById('postTypeCards');
|
||||
const selectedPostTypeIdInput = document.getElementById('selectedPostTypeId');
|
||||
|
||||
let selectedTopic = null;
|
||||
let currentVersionIndex = 0;
|
||||
let currentPostTypes = [];
|
||||
let currentTopics = [];
|
||||
|
||||
function renderVersions(versions, feedbackList) {
|
||||
if (!versions || versions.length === 0) {
|
||||
liveVersions.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
liveVersions.classList.remove('hidden');
|
||||
|
||||
// Build version tabs and content
|
||||
let html = `
|
||||
<div class="flex gap-2 mb-4 flex-wrap">
|
||||
${versions.map((_, i) => `
|
||||
<button onclick="showVersion(${i})" id="versionTab${i}"
|
||||
class="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors
|
||||
${i === currentVersionIndex ? 'bg-brand-highlight text-brand-bg-dark' : 'bg-brand-bg text-gray-300 hover:bg-brand-bg-light'}">
|
||||
V${i + 1}
|
||||
${feedbackList[i] ? `<span class="ml-1 text-xs opacity-75">(${feedbackList[i].overall_score || '?'})</span>` : ''}
|
||||
</button>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Show current version
|
||||
const currentVersion = versions[currentVersionIndex];
|
||||
const currentFeedback = feedbackList[currentVersionIndex];
|
||||
|
||||
html += `
|
||||
<div class="grid grid-cols-1 ${currentFeedback ? 'lg:grid-cols-2' : ''} gap-4">
|
||||
<div class="bg-brand-bg/50 rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-sm font-medium text-gray-300">Version ${currentVersionIndex + 1}</span>
|
||||
${currentFeedback ? `
|
||||
<span class="px-2 py-0.5 text-xs rounded ${currentFeedback.approved ? 'bg-green-600/30 text-green-300' : 'bg-yellow-600/30 text-yellow-300'}">
|
||||
${currentFeedback.approved ? 'Approved' : `Score: ${currentFeedback.overall_score}/100`}
|
||||
</span>
|
||||
` : '<span class="text-xs text-gray-500">Wird bewertet...</span>'}
|
||||
</div>
|
||||
<pre class="whitespace-pre-wrap text-gray-200 font-sans text-sm max-h-96 overflow-y-auto">${currentVersion}</pre>
|
||||
</div>
|
||||
${currentFeedback ? `
|
||||
<div class="bg-brand-bg/30 rounded-lg p-4 border border-brand-bg-light">
|
||||
<span class="text-sm font-medium text-gray-300 block mb-2">Kritik</span>
|
||||
<p class="text-sm text-gray-400 mb-3">${currentFeedback.feedback || 'Keine Kritik'}</p>
|
||||
${currentFeedback.improvements && currentFeedback.improvements.length > 0 ? `
|
||||
<div class="mt-2">
|
||||
<span class="text-xs font-medium text-gray-400">Verbesserungen:</span>
|
||||
<ul class="mt-1 space-y-1">
|
||||
${currentFeedback.improvements.map(imp => `
|
||||
<li class="text-xs text-gray-500 flex items-start gap-1">
|
||||
<span class="text-yellow-500">•</span> ${imp}
|
||||
</li>
|
||||
`).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
` : ''}
|
||||
${currentFeedback.scores ? `
|
||||
<div class="mt-3 pt-3 border-t border-brand-bg-light">
|
||||
<div class="grid grid-cols-3 gap-2 text-xs">
|
||||
<div class="text-center">
|
||||
<div class="text-gray-500">Authentizität</div>
|
||||
<div class="font-medium text-gray-300">${currentFeedback.scores.authenticity_and_style || '?'}/40</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-gray-500">Content</div>
|
||||
<div class="font-medium text-gray-300">${currentFeedback.scores.content_quality || '?'}/35</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-gray-500">Technik</div>
|
||||
<div class="font-medium text-gray-300">${currentFeedback.scores.technical_execution || '?'}/25</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
versionsContainer.innerHTML = html;
|
||||
}
|
||||
|
||||
function showVersion(index) {
|
||||
currentVersionIndex = index;
|
||||
// Get cached versions from progress store
|
||||
const cachedData = window.lastProgressData;
|
||||
if (cachedData) {
|
||||
renderVersions(cachedData.versions, cachedData.feedback_list);
|
||||
}
|
||||
}
|
||||
|
||||
// Load topics and post types on page load
|
||||
async function loadData() {
|
||||
// Load post types
|
||||
try {
|
||||
const ptResponse = await fetch('/api/post-types');
|
||||
const ptData = await ptResponse.json();
|
||||
|
||||
if (ptData.post_types && ptData.post_types.length > 0) {
|
||||
currentPostTypes = ptData.post_types;
|
||||
postTypeSelectionArea.classList.remove('hidden');
|
||||
|
||||
postTypeCards.innerHTML = `
|
||||
<button type="button" onclick="selectPostTypeForCreate('')" id="ptc_all"
|
||||
class="px-3 py-2 rounded-lg border text-sm transition-colors bg-brand-highlight/20 border-brand-highlight text-white">
|
||||
Alle Typen
|
||||
</button>
|
||||
` + ptData.post_types.map(pt => `
|
||||
<button type="button" onclick="selectPostTypeForCreate('${pt.id}')" id="ptc_${pt.id}"
|
||||
class="px-3 py-2 rounded-lg border text-sm transition-colors bg-brand-bg border-brand-bg-light hover:border-brand-highlight/50 text-white">
|
||||
${pt.name}
|
||||
${pt.has_analysis ? '<span class="ml-1 text-green-400 text-xs">*</span>' : ''}
|
||||
</button>
|
||||
`).join('');
|
||||
} else {
|
||||
postTypeSelectionArea.classList.add('hidden');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load post types:', error);
|
||||
postTypeSelectionArea.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Load topics
|
||||
loadTopics();
|
||||
}
|
||||
|
||||
async function loadTopics(postTypeId = null) {
|
||||
topicsList.innerHTML = '<p class="text-gray-500">Lade Topics...</p>';
|
||||
|
||||
try {
|
||||
let url = '/api/topics';
|
||||
if (postTypeId) {
|
||||
url += `?post_type_id=${postTypeId}`;
|
||||
}
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.topics && data.topics.length > 0) {
|
||||
renderTopicsList(data);
|
||||
} else {
|
||||
let message = '';
|
||||
if (data.used_count > 0) {
|
||||
message = `<div class="text-center py-4">
|
||||
<p class="text-gray-400 mb-2">Alle ${data.used_count} Topics wurden bereits verwendet.</p>
|
||||
<a href="/research" class="text-brand-highlight hover:underline">Neue Topics recherchieren</a>
|
||||
<p class="text-gray-500 text-sm mt-2">oder gib unten ein eigenes Topic ein.</p>
|
||||
</div>`;
|
||||
} else {
|
||||
message = `<div class="text-center py-4">
|
||||
<p class="text-gray-400 mb-2">Keine Topics gefunden${postTypeId ? ' für diesen Post-Typ' : ''}.</p>
|
||||
<a href="/research" class="text-brand-highlight hover:underline">Recherche starten</a>
|
||||
<p class="text-gray-500 text-sm mt-2">oder gib unten ein eigenes Topic ein.</p>
|
||||
</div>`;
|
||||
}
|
||||
topicsList.innerHTML = message;
|
||||
}
|
||||
} catch (error) {
|
||||
topicsList.innerHTML = `<p class="text-red-400">Fehler beim Laden: ${error.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Clear selected topic when custom topic is entered
|
||||
['customTopicTitle', 'customTopicFact', 'customTopicSource'].forEach(id => {
|
||||
document.getElementById(id).addEventListener('input', () => {
|
||||
selectedTopic = null;
|
||||
document.querySelectorAll('input[name="topic"]').forEach(radio => radio.checked = false);
|
||||
});
|
||||
});
|
||||
|
||||
function selectPostTypeForCreate(typeId) {
|
||||
selectedPostTypeIdInput.value = typeId;
|
||||
|
||||
// Update card styles
|
||||
document.querySelectorAll('[id^="ptc_"]').forEach(card => {
|
||||
if (card.id === `ptc_${typeId}` || (typeId === '' && card.id === 'ptc_all')) {
|
||||
card.className = 'px-3 py-2 rounded-lg border text-sm transition-colors bg-brand-highlight/20 border-brand-highlight text-white';
|
||||
} else {
|
||||
card.className = 'px-3 py-2 rounded-lg border text-sm transition-colors bg-brand-bg border-brand-bg-light hover:border-brand-highlight/50 text-white';
|
||||
}
|
||||
});
|
||||
|
||||
// Reload topics filtered by post type
|
||||
loadTopics(typeId);
|
||||
}
|
||||
|
||||
function renderTopicsList(data) {
|
||||
// Store topics in global array for safe access
|
||||
currentTopics = data.topics;
|
||||
|
||||
// Reset selected topic when list is re-rendered
|
||||
selectedTopic = null;
|
||||
|
||||
let statsHtml = '';
|
||||
if (data.used_count > 0) {
|
||||
statsHtml = `<p class="text-xs text-gray-500 mb-3">${data.available_count} verfügbar · ${data.used_count} bereits verwendet</p>`;
|
||||
}
|
||||
|
||||
topicsList.innerHTML = statsHtml + data.topics.map((topic, i) => `
|
||||
<label class="flex items-start gap-3 p-3 bg-brand-bg/50 rounded-lg cursor-pointer hover:bg-brand-bg transition-colors border border-transparent hover:border-brand-highlight/30">
|
||||
<input type="radio" name="topic" value="${i}" class="mt-1 text-brand-highlight" data-topic-index="${i}">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<span class="inline-block px-2 py-0.5 text-xs font-medium bg-brand-highlight/20 text-brand-highlight rounded">${escapeHtml(topic.category || 'Topic')}</span>
|
||||
${topic.target_post_type_id ? `<span class="text-xs text-gray-500">Typ-spezifisch</span>` : ''}
|
||||
${topic.source ? `<span class="text-xs text-gray-500">🔗 ${escapeHtml(topic.source.substring(0, 30))}${topic.source.length > 30 ? '...' : ''}</span>` : ''}
|
||||
</div>
|
||||
<p class="font-medium text-white">${escapeHtml(topic.title)}</p>
|
||||
${topic.angle ? `<p class="text-xs text-brand-highlight/80 mt-1">→ ${escapeHtml(topic.angle)}</p>` : ''}
|
||||
${topic.hook_idea ? `<p class="text-sm text-gray-300 mt-2 italic border-l-2 border-brand-highlight/30 pl-2">"${escapeHtml(topic.hook_idea.substring(0, 120))}${topic.hook_idea.length > 120 ? '...' : ''}"</p>` : ''}
|
||||
${topic.key_facts && topic.key_facts.length > 0 ? `
|
||||
<div class="mt-2 flex flex-wrap gap-1">
|
||||
${topic.key_facts.slice(0, 2).map(f => `<span class="text-xs bg-brand-bg-dark px-2 py-0.5 rounded text-gray-400">📊 ${escapeHtml(f.substring(0, 40))}${f.length > 40 ? '...' : ''}</span>`).join('')}
|
||||
</div>
|
||||
` : (topic.fact ? `<p class="text-sm text-gray-400 mt-1">${escapeHtml(topic.fact.substring(0, 100))}...</p>` : '')}
|
||||
${topic.why_this_person ? `<p class="text-xs text-gray-500 mt-2">💡 ${escapeHtml(topic.why_this_person.substring(0, 80))}${topic.why_this_person.length > 80 ? '...' : ''}</p>` : ''}
|
||||
</div>
|
||||
</label>
|
||||
`).join('');
|
||||
|
||||
// Add event listeners to radio buttons
|
||||
document.querySelectorAll('input[name="topic"]').forEach(radio => {
|
||||
radio.addEventListener('change', () => {
|
||||
const index = parseInt(radio.dataset.topicIndex, 10);
|
||||
selectedTopic = currentTopics[index];
|
||||
// Clear custom topic fields
|
||||
document.getElementById('customTopicTitle').value = '';
|
||||
document.getElementById('customTopicFact').value = '';
|
||||
document.getElementById('customTopicSource').value = '';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Helper function to escape HTML special characters
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Get topic (either selected or custom)
|
||||
let topic;
|
||||
const customTitle = document.getElementById('customTopicTitle').value.trim();
|
||||
const customFact = document.getElementById('customTopicFact').value.trim();
|
||||
|
||||
if (customTitle && customFact) {
|
||||
topic = {
|
||||
title: customTitle,
|
||||
fact: customFact,
|
||||
source: document.getElementById('customTopicSource').value.trim() || null,
|
||||
category: 'Custom'
|
||||
};
|
||||
} else if (selectedTopic) {
|
||||
topic = selectedTopic;
|
||||
} else {
|
||||
alert('Bitte wähle ein Topic aus oder gib ein eigenes ein.');
|
||||
return;
|
||||
}
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<svg class="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg> Generiert...';
|
||||
progressArea.classList.remove('hidden');
|
||||
postResult.innerHTML = '<p class="text-gray-400">Post wird generiert...</p>';
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('topic_json', JSON.stringify(topic));
|
||||
if (selectedPostTypeIdInput.value) {
|
||||
formData.append('post_type_id', selectedPostTypeIdInput.value);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/posts', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
const taskId = data.task_id;
|
||||
currentVersionIndex = 0;
|
||||
window.lastProgressData = null;
|
||||
|
||||
const pollInterval = setInterval(async () => {
|
||||
const statusResponse = await fetch(`/api/tasks/${taskId}`);
|
||||
const status = await statusResponse.json();
|
||||
|
||||
progressBar.style.width = `${status.progress}%`;
|
||||
progressPercent.textContent = `${status.progress}%`;
|
||||
progressMessage.textContent = status.message;
|
||||
|
||||
if (status.iteration !== undefined) {
|
||||
iterationInfo.textContent = `Iteration ${status.iteration}/${status.max_iterations}`;
|
||||
}
|
||||
|
||||
// Update live versions display
|
||||
if (status.versions && status.versions.length > 0) {
|
||||
window.lastProgressData = status;
|
||||
// Auto-select latest version
|
||||
if (status.versions.length > currentVersionIndex + 1) {
|
||||
currentVersionIndex = status.versions.length - 1;
|
||||
}
|
||||
renderVersions(status.versions, status.feedback_list || []);
|
||||
postResult.innerHTML = '<p class="text-gray-400">Siehe Live-Vorschau oben...</p>';
|
||||
}
|
||||
|
||||
if (status.status === 'completed') {
|
||||
clearInterval(pollInterval);
|
||||
progressArea.classList.add('hidden');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = '<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg> Post generieren';
|
||||
|
||||
// Keep live versions visible but update header
|
||||
const result = status.result;
|
||||
|
||||
postResult.innerHTML = `
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-2 text-sm flex-wrap">
|
||||
<span class="px-2 py-1 rounded ${result.approved ? 'bg-green-600/30 text-green-300' : 'bg-yellow-600/30 text-yellow-300'}">
|
||||
${result.approved ? 'Approved' : 'Review needed'}
|
||||
</span>
|
||||
<span class="text-gray-400">Score: ${result.final_score}/100</span>
|
||||
<span class="text-gray-400">Iterations: ${result.iterations}</span>
|
||||
</div>
|
||||
<div class="text-sm text-gray-400 mb-2">Finaler Post:</div>
|
||||
<div class="bg-brand-bg/50 rounded-lg p-4">
|
||||
<pre class="whitespace-pre-wrap text-gray-200 font-sans">${result.final_post}</pre>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="copyPost()" class="px-4 py-2 bg-brand-bg hover:bg-brand-bg-light rounded-lg text-sm text-white transition-colors">
|
||||
In Zwischenablage kopieren
|
||||
</button>
|
||||
<button onclick="toggleVersions()" class="px-4 py-2 bg-brand-bg hover:bg-brand-bg-light rounded-lg text-sm text-white transition-colors">
|
||||
Versionen ${liveVersions.classList.contains('hidden') ? 'anzeigen' : 'ausblenden'}
|
||||
</button>
|
||||
<a href="/posts/${result.post_id}" class="px-4 py-2 bg-brand-highlight hover:bg-brand-highlight/90 rounded-lg text-sm text-brand-bg-dark font-medium transition-colors">
|
||||
Post öffnen
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else if (status.status === 'error') {
|
||||
clearInterval(pollInterval);
|
||||
progressArea.classList.add('hidden');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = '<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg> Post generieren';
|
||||
postResult.innerHTML = `<p class="text-red-400">Fehler: ${status.message}</p>`;
|
||||
}
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
progressArea.classList.add('hidden');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = '<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg> Post generieren';
|
||||
postResult.innerHTML = `<p class="text-red-400">Fehler: ${error.message}</p>`;
|
||||
}
|
||||
});
|
||||
|
||||
function copyPost() {
|
||||
const postText = document.querySelector('#postResult pre').textContent;
|
||||
navigator.clipboard.writeText(postText).then(() => {
|
||||
alert('Post in Zwischenablage kopiert!');
|
||||
});
|
||||
}
|
||||
|
||||
function toggleVersions() {
|
||||
liveVersions.classList.toggle('hidden');
|
||||
}
|
||||
|
||||
// Load data on page load
|
||||
loadData();
|
||||
</script>
|
||||
{% endblock %}
|
||||
76
src/web/templates/user/dashboard.html
Normal file
76
src/web/templates/user/dashboard.html
Normal file
@@ -0,0 +1,76 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Dashboard - LinkedIn Posts{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Dashboard</h1>
|
||||
<p class="text-gray-400">Willkommen zurück, {{ session.linkedin_name or session.customer_name }}!</p>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="bg-red-900/50 border border-red-500 text-red-200 px-4 py-3 rounded-lg mb-6">
|
||||
<strong>Error:</strong> {{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
||||
<div class="card-bg rounded-xl border p-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 bg-green-600/20 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-green-400" 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"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-gray-400 text-sm">Generierte Posts</p>
|
||||
<p class="text-2xl font-bold text-white">{{ total_posts or 0 }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-bg rounded-xl border p-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 bg-brand-highlight/20 rounded-lg flex items-center justify-center">
|
||||
<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="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-gray-400 text-sm">AI Agents</p>
|
||||
<p class="text-2xl font-bold text-white">5</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="card-bg rounded-xl border p-6">
|
||||
<h2 class="text-xl font-semibold text-white mb-4">Schnellaktionen</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<a href="/research" class="flex items-center gap-3 p-4 bg-brand-bg rounded-lg hover:bg-brand-bg-light transition-colors">
|
||||
<div class="w-10 h-10 btn-primary rounded-lg flex items-center justify-center">
|
||||
<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-white">Research</p>
|
||||
<p class="text-sm text-gray-400">Topics finden</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="/create" class="flex items-center gap-3 p-4 bg-brand-bg rounded-lg hover:bg-brand-bg-light transition-colors">
|
||||
<div class="w-10 h-10 btn-primary rounded-lg flex items-center justify-center">
|
||||
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-white">Post erstellen</p>
|
||||
<p class="text-sm text-gray-400">Content generieren</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="/posts" class="flex items-center gap-3 p-4 bg-brand-bg rounded-lg hover:bg-brand-bg-light transition-colors">
|
||||
<div class="w-10 h-10 btn-primary rounded-lg flex items-center justify-center">
|
||||
<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="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-white">Meine Posts</p>
|
||||
<p class="text-sm text-gray-400">Übersicht anzeigen</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
75
src/web/templates/user/login.html
Normal file
75
src/web/templates/user/login.html
Normal file
@@ -0,0 +1,75 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Login - LinkedIn Posts</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
'brand': {
|
||||
'bg': '#3d4848',
|
||||
'bg-light': '#4a5858',
|
||||
'bg-dark': '#2d3838',
|
||||
'highlight': '#ffc700',
|
||||
'highlight-dark': '#e6b300',
|
||||
},
|
||||
'linkedin': '#0A66C2'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
body { background-color: #3d4848; }
|
||||
.btn-linkedin { background-color: #0A66C2; }
|
||||
.btn-linkedin:hover { background-color: #004182; }
|
||||
.card-bg { background-color: #4a5858; border-color: #5a6868; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="text-gray-100 min-h-screen flex items-center justify-center">
|
||||
<div class="w-full max-w-md">
|
||||
<div class="card-bg rounded-xl border p-8">
|
||||
<div class="text-center mb-8">
|
||||
<img src="/static/logo.png" alt="Logo" class="h-16 w-auto mx-auto mb-4">
|
||||
<h1 class="text-2xl font-bold text-white mb-2">LinkedIn Posts</h1>
|
||||
<p class="text-gray-400">AI Workflow System</p>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="bg-red-900/50 border border-red-500 text-red-200 px-4 py-3 rounded-lg mb-6">
|
||||
{% if error == 'access_denied' %}
|
||||
Zugriff verweigert. Bitte versuche es erneut.
|
||||
{% elif error == 'unauthorized' %}
|
||||
Dein LinkedIn-Profil ist nicht autorisiert.
|
||||
{% else %}
|
||||
Fehler bei der Anmeldung: {{ error }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="space-y-6">
|
||||
<a href="/auth/linkedin" class="w-full btn-linkedin text-white font-medium py-3 px-4 rounded-lg transition-colors flex items-center justify-center gap-3">
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
|
||||
</svg>
|
||||
Mit LinkedIn anmelden
|
||||
</a>
|
||||
|
||||
<p class="text-center text-gray-500 text-sm">
|
||||
Melde dich mit deinem LinkedIn-Konto an, um auf das Dashboard zuzugreifen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 text-center">
|
||||
<a href="/admin/login" class="text-gray-500 hover:text-gray-300 text-sm">
|
||||
Admin-Login
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
40
src/web/templates/user/not_authorized.html
Normal file
40
src/web/templates/user/not_authorized.html
Normal file
@@ -0,0 +1,40 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Nicht autorisiert - LinkedIn Posts</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
body { background-color: #3d4848; }
|
||||
.card-bg { background-color: #4a5858; border-color: #5a6868; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="text-gray-100 min-h-screen flex items-center justify-center">
|
||||
<div class="w-full max-w-md">
|
||||
<div class="card-bg rounded-xl border p-8 text-center">
|
||||
<div class="w-20 h-20 bg-red-900/30 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<svg class="w-10 h-10 text-red-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"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h1 class="text-2xl font-bold text-white mb-4">Nicht autorisiert</h1>
|
||||
|
||||
<p class="text-gray-400 mb-6">
|
||||
Dein LinkedIn-Profil ist nicht mit einem Kundenkonto verknüpft.
|
||||
Bitte kontaktiere den Administrator, um Zugang zu erhalten.
|
||||
</p>
|
||||
|
||||
<div class="space-y-3">
|
||||
<a href="/login" class="block w-full bg-brand-bg hover:bg-brand-bg-light text-white font-medium py-3 px-4 rounded-lg transition-colors">
|
||||
Zurück zur Anmeldung
|
||||
</a>
|
||||
<a href="/admin/login" class="block w-full text-gray-400 hover:text-white text-sm transition-colors py-2">
|
||||
Admin-Login
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
698
src/web/templates/user/post_detail.html
Normal file
698
src/web/templates/user/post_detail.html
Normal file
@@ -0,0 +1,698 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ post.topic_title }} - Post Details{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<style>
|
||||
.section-card {
|
||||
background: rgba(61, 72, 72, 0.3);
|
||||
border: 1px solid rgba(61, 72, 72, 0.6);
|
||||
}
|
||||
.title-truncate {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.reference-post {
|
||||
background: linear-gradient(135deg, rgba(61, 72, 72, 0.4) 0%, rgba(45, 56, 56, 0.6) 100%);
|
||||
border: 1px solid rgba(255, 199, 0, 0.15);
|
||||
position: relative;
|
||||
}
|
||||
.reference-post::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
background: linear-gradient(180deg, #ffc700 0%, rgba(255, 199, 0, 0.3) 100%);
|
||||
border-radius: 3px 0 0 3px;
|
||||
}
|
||||
.profile-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
.profile-item {
|
||||
background: rgba(45, 56, 56, 0.5);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid rgba(61, 72, 72, 0.8);
|
||||
}
|
||||
.stat-bar {
|
||||
height: 6px;
|
||||
background: rgba(45, 56, 56, 0.8);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.stat-bar-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #ffc700 0%, #ffdb4d 100%);
|
||||
border-radius: 3px;
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
/* LinkedIn Preview styles */
|
||||
.linkedin-preview {
|
||||
background: #ffffff;
|
||||
border-radius: 8px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
color: rgba(0, 0, 0, 0.9);
|
||||
overflow: hidden;
|
||||
}
|
||||
.linkedin-header {
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
.linkedin-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #0a66c2 0%, #004182 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.linkedin-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.linkedin-user-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.linkedin-name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: rgba(0, 0, 0, 0.9);
|
||||
line-height: 1.3;
|
||||
}
|
||||
.linkedin-headline {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
line-height: 1.3;
|
||||
margin-top: 2px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.linkedin-timestamp {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.linkedin-content {
|
||||
padding: 0 16px 12px;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: rgba(0, 0, 0, 0.9);
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
.linkedin-content.collapsed {
|
||||
max-height: 120px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
.linkedin-content.collapsed::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 40px;
|
||||
background: linear-gradient(transparent, white);
|
||||
}
|
||||
.linkedin-see-more {
|
||||
padding: 0 16px 12px;
|
||||
font-size: 14px;
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
.linkedin-see-more:hover {
|
||||
color: #0a66c2;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.linkedin-engagement {
|
||||
padding: 8px 16px;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.08);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.linkedin-actions {
|
||||
display: flex;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
.linkedin-action-btn {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 12px 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: default;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.linkedin-action-btn svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
.linkedin-more-btn {
|
||||
padding: 4px;
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
border-radius: 50%;
|
||||
cursor: default;
|
||||
}
|
||||
.view-toggle {
|
||||
display: inline-flex;
|
||||
background: rgba(61, 72, 72, 0.5);
|
||||
border-radius: 8px;
|
||||
padding: 2px;
|
||||
}
|
||||
.view-toggle-btn {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border-radius: 6px;
|
||||
color: #9ca3af;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.view-toggle-btn.active {
|
||||
background: rgba(255, 199, 0, 0.2);
|
||||
color: #ffc700;
|
||||
}
|
||||
.view-toggle-btn:hover:not(.active) {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
/* Tab styles */
|
||||
.tab-btn {
|
||||
position: relative;
|
||||
padding: 0.75rem 1.25rem;
|
||||
font-weight: 500;
|
||||
color: #9ca3af;
|
||||
transition: all 0.2s ease;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
.tab-btn:hover {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
.tab-btn.active {
|
||||
color: #ffc700;
|
||||
border-bottom-color: #ffc700;
|
||||
}
|
||||
.tab-btn.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -1px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: #ffc700;
|
||||
}
|
||||
.tab-content {
|
||||
display: none;
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(5px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Breadcrumb & Header -->
|
||||
<div class="mb-6">
|
||||
<a href="/posts" class="inline-flex items-center gap-2 text-gray-400 hover:text-brand-highlight transition-colors mb-4">
|
||||
<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="M15 19l-7-7 7-7"/></svg>
|
||||
Zurück zu meinen Posts
|
||||
</a>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h1 class="text-2xl font-bold text-white mb-2 title-truncate" title="{{ post.topic_title or 'Untitled Post' }}">
|
||||
{{ post.topic_title or 'Untitled Post' }}
|
||||
</h1>
|
||||
<div class="flex items-center gap-3 text-sm text-gray-400 flex-wrap">
|
||||
<span>{{ post.created_at.strftime('%d.%m.%Y um %H:%M Uhr') if post.created_at else 'N/A' }}</span>
|
||||
<span class="text-gray-600">|</span>
|
||||
<span>{{ post.iterations }} Iteration{{ 's' if post.iterations != 1 else '' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 flex-shrink-0">
|
||||
<span class="px-3 py-1.5 rounded-lg text-sm font-medium {{ 'bg-green-600/30 text-green-300 border border-green-600/50' if post.status == 'approved' else 'bg-yellow-600/30 text-yellow-300 border border-yellow-600/50' }}">
|
||||
{{ post.status | capitalize }}
|
||||
</span>
|
||||
{% if final_feedback %}
|
||||
<span class="px-3 py-1.5 rounded-lg text-sm font-bold {{ 'bg-green-600/30 text-green-300' if final_feedback.overall_score >= 85 else 'bg-yellow-600/30 text-yellow-300' if final_feedback.overall_score >= 70 else 'bg-red-600/30 text-red-300' }}">
|
||||
Score: {{ final_feedback.overall_score }}/100
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab Navigation -->
|
||||
<div class="mb-6 border-b border-brand-bg-light">
|
||||
<nav class="flex gap-1" role="tablist">
|
||||
<button class="tab-btn active" onclick="switchTab('ergebnis')" role="tab" aria-selected="true" data-tab="ergebnis">
|
||||
<svg class="w-4 h-4 inline-block mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
Ergebnis
|
||||
</button>
|
||||
{% if post.writer_versions and post.writer_versions | length > 0 %}
|
||||
<button class="tab-btn" onclick="switchTab('iterationen')" role="tab" aria-selected="false" data-tab="iterationen">
|
||||
<svg class="w-4 h-4 inline-block mr-2" 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>
|
||||
Iterationen
|
||||
<span class="ml-1 px-1.5 py-0.5 text-xs bg-brand-bg rounded">{{ post.writer_versions | length }}</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Tab: Ergebnis (mit Sidebar) -->
|
||||
<div id="tab-ergebnis" class="tab-content active">
|
||||
<div class="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||
<!-- Post Content -->
|
||||
<div class="xl:col-span-2">
|
||||
<div class="section-card rounded-xl p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
Finaler Post
|
||||
</h2>
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- View Toggle -->
|
||||
<div class="view-toggle">
|
||||
<button onclick="setView('preview')" id="previewToggle" class="view-toggle-btn active">
|
||||
<svg class="w-4 h-4 inline-block mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
|
||||
Preview
|
||||
</button>
|
||||
<button onclick="setView('raw')" id="rawToggle" class="view-toggle-btn">
|
||||
<svg class="w-4 h-4 inline-block mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"/></svg>
|
||||
Raw
|
||||
</button>
|
||||
</div>
|
||||
<button onclick="copyToClipboard()" class="px-3 py-1.5 bg-brand-bg hover:bg-brand-bg-light rounded-lg text-sm text-gray-300 transition-colors flex items-center gap-2">
|
||||
<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="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>
|
||||
Kopieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- LinkedIn Preview View -->
|
||||
<div id="linkedinPreview" class="linkedin-preview shadow-lg">
|
||||
<div class="linkedin-header">
|
||||
<div class="linkedin-avatar">
|
||||
{% if profile_picture_url %}
|
||||
<img src="{{ profile_picture_url }}" alt="{{ session.linkedin_name }}" loading="lazy" referrerpolicy="no-referrer">
|
||||
{% else %}
|
||||
{{ session.linkedin_name[:2] | upper if session.linkedin_name else 'UN' }}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="linkedin-user-info">
|
||||
<div class="linkedin-name">{{ session.linkedin_name or 'LinkedIn User' }}</div>
|
||||
<div class="linkedin-headline">{{ session.customer_name or 'LinkedIn Member' }}</div>
|
||||
<div class="linkedin-timestamp">
|
||||
<span>Jetzt</span>
|
||||
<span>•</span>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M8 1a7 7 0 107 7 7 7 0 00-7-7zM3 8a5 5 0 011-3l.55.55A1.5 1.5 0 015 6.62v1.07a.75.75 0 00.22.53l.56.56a.75.75 0 00.53.22H7v.69a.75.75 0 00.22.53l.56.56a.75.75 0 01.22.53V13a5 5 0 01-5-5zm6.24 4.83l2-2.46a.75.75 0 00.09-.8l-.58-1.16A.76.76 0 0010 8H7v-.19a.51.51 0 01.28-.45l.38-.19a.74.74 0 00.3-1L7.4 5.19a.75.75 0 00-.67-.41H5.67a.75.75 0 01-.44-.14l-.34-.26a5 5 0 017.35 8.44z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="linkedin-more-btn">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M14 12a2 2 0 11-4 0 2 2 0 014 0zM4 12a2 2 0 11-4 0 2 2 0 014 0zm16 0a2 2 0 11-4 0 2 2 0 014 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div id="linkedinContent" class="linkedin-content collapsed">{{ post.post_content }}</div>
|
||||
<div id="seeMoreBtn" class="linkedin-see-more" onclick="toggleContent()">...mehr anzeigen</div>
|
||||
<div class="linkedin-engagement">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="#0a66c2">
|
||||
<path d="M19.46 11l-3.91-3.91a7 7 0 01-1.69-2.74l-.49-1.47A2.76 2.76 0 0010.76 1 2.75 2.75 0 008 3.74v1.12a9.19 9.19 0 00.46 2.85L8.89 9H4.12A2.12 2.12 0 002 11.12a2.16 2.16 0 00.92 1.76A2.11 2.11 0 002 14.62a2.14 2.14 0 001.28 2 2 2 0 00-.28 1 2.12 2.12 0 002 2.12v.14A2.12 2.12 0 007.12 22h7.49a8.08 8.08 0 003.58-.84l.31-.16H21V11z"/>
|
||||
</svg>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="#df704d" style="margin-left: -6px;">
|
||||
<path d="M22.51 11.35a5.84 5.84 0 00-2.53-4.83 5.71 5.71 0 00-5.36-.58 5.64 5.64 0 00-2.62 2.09 5.64 5.64 0 00-2.62-2.09 5.71 5.71 0 00-5.36.58 5.84 5.84 0 00-2.53 4.83 6.6 6.6 0 00.49 2.56c1 2.33 9.91 8.09 9.91 8.09s8.92-5.76 9.91-8.09a6.6 6.6 0 00.71-2.56z"/>
|
||||
</svg>
|
||||
<span style="margin-left: 4px;">42</span>
|
||||
<span style="margin-left: auto;">12 Kommentare • 3 Reposts</span>
|
||||
</div>
|
||||
<div class="linkedin-actions">
|
||||
<button class="linkedin-action-btn">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19.46 11l-3.91-3.91a7 7 0 01-1.69-2.74l-.49-1.47A2.76 2.76 0 0010.76 1 2.75 2.75 0 008 3.74v1.12a9.19 9.19 0 00.46 2.85L8.89 9H4.12A2.12 2.12 0 002 11.12a2.16 2.16 0 00.92 1.76A2.11 2.11 0 002 14.62a2.14 2.14 0 001.28 2 2 2 0 00-.28 1 2.12 2.12 0 002 2.12v.14A2.12 2.12 0 007.12 22h7.49a8.08 8.08 0 003.58-.84l.31-.16H21V11z"/>
|
||||
</svg>
|
||||
Gefällt mir
|
||||
</button>
|
||||
<button class="linkedin-action-btn">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M7 9h10v1H7zm0 4h7v-1H7zm16-2a6.78 6.78 0 01-2.84 5.61L12 22v-4H8A7 7 0 018 4h8a7 7 0 017 7z"/>
|
||||
</svg>
|
||||
Kommentieren
|
||||
</button>
|
||||
<button class="linkedin-action-btn">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M13.96 5H6c-.55 0-1 .45-1 1v11H3V6c0-1.66 1.34-3 3-3h7.96L12 0l1.96 5zM17 7h-7c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h7c1.1 0 2-.9 2-2V9c0-1.1-.9-2-2-2z"/>
|
||||
</svg>
|
||||
Reposten
|
||||
</button>
|
||||
<button class="linkedin-action-btn">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M21 3L0 10l7.66 4.26L16 8l-6.26 8.34L14 24l7-21z"/>
|
||||
</svg>
|
||||
Senden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Raw View -->
|
||||
<div id="rawView" class="bg-brand-bg/50 rounded-lg p-5 hidden">
|
||||
<pre id="finalPostContent" class="whitespace-pre-wrap text-gray-200 font-sans text-sm leading-relaxed">{{ post.post_content }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<div class="space-y-6">
|
||||
<!-- Score Breakdown -->
|
||||
{% if final_feedback and final_feedback.scores %}
|
||||
<div class="section-card rounded-xl p-6">
|
||||
<h3 class="font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/></svg>
|
||||
Score-Aufschlüsselung
|
||||
</h3>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<div class="flex justify-between text-sm mb-2">
|
||||
<span class="text-gray-400">Authentizität & Stil</span>
|
||||
<span class="text-white font-medium">{{ final_feedback.scores.authenticity_and_style }}/40</span>
|
||||
</div>
|
||||
<div class="stat-bar">
|
||||
<div class="stat-bar-fill" style="width: {{ (final_feedback.scores.authenticity_and_style / 40 * 100) | int }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex justify-between text-sm mb-2">
|
||||
<span class="text-gray-400">Content-Qualität</span>
|
||||
<span class="text-white font-medium">{{ final_feedback.scores.content_quality }}/35</span>
|
||||
</div>
|
||||
<div class="stat-bar">
|
||||
<div class="stat-bar-fill" style="width: {{ (final_feedback.scores.content_quality / 35 * 100) | int }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex justify-between text-sm mb-2">
|
||||
<span class="text-gray-400">Technische Umsetzung</span>
|
||||
<span class="text-white font-medium">{{ final_feedback.scores.technical_execution }}/25</span>
|
||||
</div>
|
||||
<div class="stat-bar">
|
||||
<div class="stat-bar-fill" style="width: {{ (final_feedback.scores.technical_execution / 25 * 100) | int }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pt-3 border-t border-brand-bg-light">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-300 font-medium">Gesamt</span>
|
||||
<span class="text-brand-highlight font-bold text-lg">{{ final_feedback.overall_score }}/100</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Final Feedback Summary -->
|
||||
{% if final_feedback %}
|
||||
<div class="section-card rounded-xl p-6">
|
||||
<h3 class="font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"/></svg>
|
||||
Finales Feedback
|
||||
</h3>
|
||||
<p class="text-sm text-gray-300 mb-4">{{ final_feedback.feedback }}</p>
|
||||
{% if final_feedback.strengths %}
|
||||
<div class="bg-green-900/10 rounded-lg p-3 border border-green-600/20">
|
||||
<span class="text-xs font-medium text-green-400 block mb-2">Stärken</span>
|
||||
<ul class="space-y-1">
|
||||
{% for s in final_feedback.strengths %}
|
||||
<li class="text-sm text-gray-400 flex items-start gap-2"><span class="text-green-400">+</span> {{ s }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="section-card rounded-xl p-6">
|
||||
<h3 class="font-semibold text-white mb-4">Aktionen</h3>
|
||||
<div class="space-y-2">
|
||||
<button onclick="copyToClipboard()" class="w-full px-4 py-2.5 bg-brand-highlight hover:bg-brand-highlight/90 text-brand-bg-dark font-medium rounded-lg transition-colors flex items-center justify-center gap-2">
|
||||
<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="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>
|
||||
Post kopieren
|
||||
</button>
|
||||
<a href="/create" class="w-full px-4 py-2.5 bg-brand-bg hover:bg-brand-bg-light text-white rounded-lg transition-colors flex items-center justify-center gap-2">
|
||||
<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="M12 6v6m0 0v6m0-6h6m-6 0H6"/></svg>
|
||||
Neuen Post erstellen
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab: Iterationen -->
|
||||
{% if post.writer_versions and post.writer_versions | length > 0 %}
|
||||
<div id="tab-iterationen" class="tab-content">
|
||||
<div class="section-card rounded-xl p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
Iterationen
|
||||
</h2>
|
||||
<!-- Pagination Controls -->
|
||||
<div class="flex items-center gap-2">
|
||||
<button id="prevVersion" onclick="changeVersion(-1)" class="p-2 rounded-lg bg-brand-bg hover:bg-brand-bg-light text-gray-400 hover:text-white transition-colors disabled:opacity-30 disabled:cursor-not-allowed">
|
||||
<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="M15 19l-7-7 7-7"/></svg>
|
||||
</button>
|
||||
<span class="text-sm text-gray-400">
|
||||
<span id="currentVersionNum">1</span> / {{ post.writer_versions | length }}
|
||||
</span>
|
||||
<button id="nextVersion" onclick="changeVersion(1)" class="p-2 rounded-lg bg-brand-bg hover:bg-brand-bg-light text-gray-400 hover:text-white transition-colors disabled:opacity-30 disabled:cursor-not-allowed">
|
||||
<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="M9 5l7 7-7 7"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Version Content Container -->
|
||||
{% for i in range(post.writer_versions | length) %}
|
||||
<div class="version-panel {% if i == 0 %}block{% else %}hidden{% endif %}" data-version="{{ i }}">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<span class="px-3 py-1 bg-brand-highlight/20 text-brand-highlight rounded-lg text-sm font-medium">Version {{ i + 1 }}</span>
|
||||
{% if post.critic_feedback and i < post.critic_feedback | length %}
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="px-2 py-1 rounded text-xs font-medium {{ 'bg-green-600/30 text-green-300' if post.critic_feedback[i].overall_score >= 85 else 'bg-yellow-600/30 text-yellow-300' }}">
|
||||
Score: {{ post.critic_feedback[i].overall_score }}/100
|
||||
</span>
|
||||
{% if post.critic_feedback[i].approved %}
|
||||
<span class="px-2 py-1 bg-green-600/30 text-green-300 rounded text-xs">Approved</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="bg-brand-bg/30 rounded-lg p-4 mb-4">
|
||||
<pre class="whitespace-pre-wrap text-gray-300 font-sans text-sm">{{ post.writer_versions[i] }}</pre>
|
||||
</div>
|
||||
|
||||
{% if post.critic_feedback and i < post.critic_feedback | length %}
|
||||
{% set fb = post.critic_feedback[i] %}
|
||||
<div class="bg-brand-bg/20 rounded-lg p-4 border border-brand-bg-light">
|
||||
<h4 class="text-sm font-medium text-white mb-2 flex items-center gap-2">
|
||||
<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 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"/></svg>
|
||||
Critic Feedback
|
||||
</h4>
|
||||
<p class="text-sm text-gray-400 mb-3">{{ fb.feedback }}</p>
|
||||
<div class="grid md:grid-cols-2 gap-4">
|
||||
{% if fb.strengths %}
|
||||
<div class="bg-green-900/10 rounded-lg p-3 border border-green-600/20">
|
||||
<span class="text-xs font-medium text-green-400 block mb-2">Stärken</span>
|
||||
<ul class="space-y-1 text-sm text-gray-400">
|
||||
{% for s in fb.strengths %}
|
||||
<li class="flex items-start gap-2"><span class="text-green-400 mt-0.5">+</span> {{ s }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if fb.improvements %}
|
||||
<div class="bg-yellow-900/10 rounded-lg p-3 border border-yellow-600/20">
|
||||
<span class="text-xs font-medium text-yellow-400 block mb-2">Verbesserungen</span>
|
||||
<ul class="space-y-1 text-sm text-gray-400">
|
||||
{% for imp in fb.improvements %}
|
||||
<li class="flex items-start gap-2"><span class="text-yellow-400 mt-0.5">-</span> {{ imp }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
let currentVersion = 0;
|
||||
const totalVersions = {{ post.writer_versions | length if post.writer_versions else 0 }};
|
||||
let currentView = 'preview';
|
||||
let contentExpanded = false;
|
||||
|
||||
// LinkedIn Preview functions
|
||||
function setView(view) {
|
||||
currentView = view;
|
||||
const previewEl = document.getElementById('linkedinPreview');
|
||||
const rawEl = document.getElementById('rawView');
|
||||
const previewToggle = document.getElementById('previewToggle');
|
||||
const rawToggle = document.getElementById('rawToggle');
|
||||
|
||||
if (view === 'preview') {
|
||||
previewEl.classList.remove('hidden');
|
||||
rawEl.classList.add('hidden');
|
||||
previewToggle.classList.add('active');
|
||||
rawToggle.classList.remove('active');
|
||||
} else {
|
||||
previewEl.classList.add('hidden');
|
||||
rawEl.classList.remove('hidden');
|
||||
previewToggle.classList.remove('active');
|
||||
rawToggle.classList.add('active');
|
||||
}
|
||||
}
|
||||
|
||||
function toggleContent() {
|
||||
const contentEl = document.getElementById('linkedinContent');
|
||||
const seeMoreBtn = document.getElementById('seeMoreBtn');
|
||||
|
||||
if (contentExpanded) {
|
||||
contentEl.classList.add('collapsed');
|
||||
seeMoreBtn.textContent = '...mehr anzeigen';
|
||||
seeMoreBtn.style.display = 'block';
|
||||
} else {
|
||||
contentEl.classList.remove('collapsed');
|
||||
seeMoreBtn.textContent = '...weniger anzeigen';
|
||||
}
|
||||
contentExpanded = !contentExpanded;
|
||||
}
|
||||
|
||||
function initLinkedInPreview() {
|
||||
const contentEl = document.getElementById('linkedinContent');
|
||||
const seeMoreBtn = document.getElementById('seeMoreBtn');
|
||||
|
||||
if (contentEl && seeMoreBtn) {
|
||||
// Check if content is short enough to not need "see more"
|
||||
const maxCollapsedHeight = 120;
|
||||
contentEl.classList.remove('collapsed');
|
||||
const fullHeight = contentEl.scrollHeight;
|
||||
contentEl.classList.add('collapsed');
|
||||
|
||||
if (fullHeight <= maxCollapsedHeight) {
|
||||
// Content is short, hide "see more" button and remove collapsed class
|
||||
seeMoreBtn.style.display = 'none';
|
||||
contentEl.classList.remove('collapsed');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tab switching function
|
||||
function switchTab(tabName) {
|
||||
// Hide all tab contents
|
||||
document.querySelectorAll('.tab-content').forEach(content => {
|
||||
content.classList.remove('active');
|
||||
});
|
||||
|
||||
// Deactivate all tab buttons
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
btn.setAttribute('aria-selected', 'false');
|
||||
});
|
||||
|
||||
// Show selected tab content
|
||||
const tabContent = document.getElementById('tab-' + tabName);
|
||||
if (tabContent) {
|
||||
tabContent.classList.add('active');
|
||||
}
|
||||
|
||||
// Activate selected tab button
|
||||
const tabBtn = document.querySelector(`.tab-btn[data-tab="${tabName}"]`);
|
||||
if (tabBtn) {
|
||||
tabBtn.classList.add('active');
|
||||
tabBtn.setAttribute('aria-selected', 'true');
|
||||
}
|
||||
}
|
||||
|
||||
function copyToClipboard() {
|
||||
const content = document.getElementById('finalPostContent').textContent;
|
||||
navigator.clipboard.writeText(content).then(() => {
|
||||
const btn = event.target.closest('button');
|
||||
const originalHTML = btn.innerHTML;
|
||||
btn.innerHTML = '<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="M5 13l4 4L19 7"/></svg> Kopiert!';
|
||||
setTimeout(() => btn.innerHTML = originalHTML, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
function changeVersion(delta) {
|
||||
const newVersion = currentVersion + delta;
|
||||
if (newVersion < 0 || newVersion >= totalVersions) return;
|
||||
|
||||
// Hide current
|
||||
document.querySelector(`.version-panel[data-version="${currentVersion}"]`).classList.add('hidden');
|
||||
document.querySelector(`.version-panel[data-version="${currentVersion}"]`).classList.remove('block');
|
||||
|
||||
// Show new
|
||||
currentVersion = newVersion;
|
||||
document.querySelector(`.version-panel[data-version="${currentVersion}"]`).classList.remove('hidden');
|
||||
document.querySelector(`.version-panel[data-version="${currentVersion}"]`).classList.add('block');
|
||||
|
||||
// Update counter
|
||||
document.getElementById('currentVersionNum').textContent = currentVersion + 1;
|
||||
|
||||
// Update button states
|
||||
document.getElementById('prevVersion').disabled = currentVersion === 0;
|
||||
document.getElementById('nextVersion').disabled = currentVersion === totalVersions - 1;
|
||||
}
|
||||
|
||||
// Initialize
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
if (totalVersions > 0) {
|
||||
document.getElementById('prevVersion').disabled = true;
|
||||
document.getElementById('nextVersion').disabled = totalVersions <= 1;
|
||||
}
|
||||
// Initialize LinkedIn preview
|
||||
initLinkedInPreview();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
114
src/web/templates/user/posts.html
Normal file
114
src/web/templates/user/posts.html
Normal file
@@ -0,0 +1,114 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Meine Posts - LinkedIn Posts{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<style>
|
||||
.post-card {
|
||||
background: linear-gradient(135deg, rgba(61, 72, 72, 0.3) 0%, rgba(45, 56, 56, 0.4) 100%);
|
||||
border: 1px solid rgba(61, 72, 72, 0.6);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.post-card:hover {
|
||||
border-color: rgba(255, 199, 0, 0.3);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.score-ring {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.score-high { background: rgba(34, 197, 94, 0.2); border: 2px solid rgba(34, 197, 94, 0.5); color: #86efac; }
|
||||
.score-medium { background: rgba(234, 179, 8, 0.2); border: 2px solid rgba(234, 179, 8, 0.5); color: #fde047; }
|
||||
.score-low { background: rgba(239, 68, 68, 0.2); border: 2px solid rgba(239, 68, 68, 0.5); color: #fca5a5; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Meine Posts</h1>
|
||||
<p class="text-gray-400">{{ total_posts }} generierte Posts</p>
|
||||
</div>
|
||||
<a href="/create" class="px-4 py-2.5 btn-primary rounded-lg font-medium flex items-center gap-2">
|
||||
<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="M12 6v6m0 0v6m0-6h6m-6 0H6"/></svg>
|
||||
Neuer Post
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="bg-red-900/50 border border-red-500 text-red-200 px-4 py-3 rounded-lg mb-6">
|
||||
<strong>Error:</strong> {{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if posts %}
|
||||
<div class="card-bg rounded-xl border overflow-hidden">
|
||||
<div class="p-4">
|
||||
<div class="grid gap-3">
|
||||
{% for post in posts %}
|
||||
<a href="/posts/{{ post.id }}" class="post-card rounded-xl p-4 block group">
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Score Circle -->
|
||||
{% if post.critic_feedback and post.critic_feedback | length > 0 %}
|
||||
{% set score = post.critic_feedback[-1].overall_score %}
|
||||
<div class="score-ring flex-shrink-0 {{ 'score-high' if score >= 85 else 'score-medium' if score >= 70 else 'score-low' }}">
|
||||
{{ score }}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="score-ring flex-shrink-0 bg-brand-bg-dark border-2 border-brand-bg-light text-gray-500">
|
||||
—
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<h4 class="font-medium text-white group-hover:text-brand-highlight transition-colors truncate">
|
||||
{{ post.topic_title or 'Untitled' }}
|
||||
</h4>
|
||||
<span class="flex-shrink-0 px-2 py-0.5 text-xs rounded font-medium {{ 'bg-green-600/20 text-green-400 border border-green-600/30' if post.status == 'approved' else 'bg-yellow-600/20 text-yellow-400 border border-yellow-600/30' }}">
|
||||
{{ post.status | capitalize }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4 mt-1.5 text-sm text-gray-500">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
|
||||
{{ post.created_at.strftime('%d.%m.%Y') if post.created_at else 'N/A' }}
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
|
||||
{{ post.iterations }} Iteration{{ 's' if post.iterations != 1 else '' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Arrow -->
|
||||
<svg class="w-5 h-5 text-gray-600 group-hover:text-brand-highlight transition-colors flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card-bg rounded-xl border p-12 text-center">
|
||||
<div class="w-20 h-20 bg-brand-bg rounded-2xl flex items-center justify-center mx-auto mb-6">
|
||||
<svg class="w-10 h-10 text-gray-600" 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"/></svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-white mb-2">Noch keine Posts</h3>
|
||||
<p class="text-gray-400 mb-6 max-w-md mx-auto">Erstelle deinen ersten LinkedIn Post mit KI-Unterstützung.</p>
|
||||
<a href="/create" class="inline-flex items-center gap-2 px-6 py-3 btn-primary font-medium rounded-lg transition-colors">
|
||||
<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="M12 6v6m0 0v6m0-6h6m-6 0H6"/></svg>
|
||||
Post erstellen
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
185
src/web/templates/user/research.html
Normal file
185
src/web/templates/user/research.html
Normal file
@@ -0,0 +1,185 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Research Topics - LinkedIn Posts{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Research Topics</h1>
|
||||
<p class="text-gray-400">Recherchiere neue Content-Themen mit Perplexity AI</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<!-- Left: Form -->
|
||||
<div>
|
||||
<form id="researchForm" class="card-bg rounded-xl border p-6">
|
||||
<!-- Post Type Selection -->
|
||||
<div id="postTypeArea" class="mb-6">
|
||||
<label class="block text-sm font-medium text-gray-300 mb-2">Post-Typ (optional)</label>
|
||||
<div id="postTypeCards" class="grid grid-cols-2 gap-2 mb-2">
|
||||
<div class="text-gray-500 text-sm">Lade Post-Typen...</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500">Wähle einen Post-Typ für gezielte Recherche oder lasse leer für allgemeine Recherche.</p>
|
||||
<input type="hidden" name="post_type_id" id="selectedPostTypeId" value="">
|
||||
</div>
|
||||
|
||||
<!-- Progress Area -->
|
||||
<div id="progressArea" class="hidden mb-6">
|
||||
<div class="bg-brand-bg rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span id="progressMessage" class="text-gray-300">Starte Recherche...</span>
|
||||
<span id="progressPercent" class="text-gray-400">0%</span>
|
||||
</div>
|
||||
<div class="w-full bg-brand-bg-dark rounded-full h-2">
|
||||
<div id="progressBar" class="bg-brand-highlight h-2 rounded-full transition-all duration-300" style="width: 0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" id="submitBtn" class="w-full btn-primary font-medium py-3 rounded-lg transition-colors flex items-center justify-center gap-2">
|
||||
<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
|
||||
Research starten
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Right: Results -->
|
||||
<div>
|
||||
<div id="resultsArea" class="card-bg rounded-xl border p-6">
|
||||
<h3 class="text-lg font-semibold text-white mb-4">Gefundene Topics</h3>
|
||||
<div id="topicsList" class="space-y-4">
|
||||
<p class="text-gray-400">Starte eine Recherche um Topics zu finden...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const form = document.getElementById('researchForm');
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
const progressArea = document.getElementById('progressArea');
|
||||
const progressBar = document.getElementById('progressBar');
|
||||
const progressMessage = document.getElementById('progressMessage');
|
||||
const progressPercent = document.getElementById('progressPercent');
|
||||
const topicsList = document.getElementById('topicsList');
|
||||
const postTypeCards = document.getElementById('postTypeCards');
|
||||
const selectedPostTypeId = document.getElementById('selectedPostTypeId');
|
||||
|
||||
let currentPostTypes = [];
|
||||
|
||||
// Load post types on page load
|
||||
async function loadPostTypes() {
|
||||
try {
|
||||
const response = await fetch('/api/post-types');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.post_types && data.post_types.length > 0) {
|
||||
currentPostTypes = data.post_types;
|
||||
|
||||
postTypeCards.innerHTML = `
|
||||
<button type="button" onclick="selectPostType('')" id="pt_all"
|
||||
class="p-3 rounded-lg border text-left transition-colors bg-brand-highlight/20 border-brand-highlight text-white">
|
||||
<div class="font-medium text-sm">Alle Typen</div>
|
||||
<div class="text-xs text-gray-400 mt-1">Allgemeine Recherche</div>
|
||||
</button>
|
||||
` + data.post_types.map(pt => `
|
||||
<button type="button" onclick="selectPostType('${pt.id}')" id="pt_${pt.id}"
|
||||
class="p-3 rounded-lg border text-left transition-colors bg-brand-bg border-brand-bg-light hover:border-brand-highlight/50 text-white">
|
||||
<div class="font-medium text-sm">${pt.name}</div>
|
||||
<div class="text-xs text-gray-400 mt-1">${pt.analyzed_post_count || 0} Posts analysiert</div>
|
||||
${pt.has_analysis ? '<span class="text-xs text-green-400">Analyse</span>' : ''}
|
||||
</button>
|
||||
`).join('');
|
||||
} else {
|
||||
postTypeCards.innerHTML = '<p class="text-gray-500 text-sm col-span-2">Keine Post-Typen konfiguriert.</p>';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load post types:', error);
|
||||
postTypeCards.innerHTML = '<p class="text-red-400 text-sm col-span-2">Fehler beim Laden.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
function selectPostType(typeId) {
|
||||
selectedPostTypeId.value = typeId;
|
||||
|
||||
document.querySelectorAll('[id^="pt_"]').forEach(card => {
|
||||
if (card.id === `pt_${typeId}` || (typeId === '' && card.id === 'pt_all')) {
|
||||
card.className = 'p-3 rounded-lg border text-left transition-colors bg-brand-highlight/20 border-brand-highlight text-white';
|
||||
} else {
|
||||
card.className = 'p-3 rounded-lg border text-left transition-colors bg-brand-bg border-brand-bg-light hover:border-brand-highlight/50 text-white';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<svg class="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg> Recherchiert...';
|
||||
progressArea.classList.remove('hidden');
|
||||
|
||||
const formData = new FormData();
|
||||
if (selectedPostTypeId.value) {
|
||||
formData.append('post_type_id', selectedPostTypeId.value);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/research', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
const taskId = data.task_id;
|
||||
const pollInterval = setInterval(async () => {
|
||||
const statusResponse = await fetch(`/api/tasks/${taskId}`);
|
||||
const status = await statusResponse.json();
|
||||
|
||||
progressBar.style.width = `${status.progress}%`;
|
||||
progressPercent.textContent = `${status.progress}%`;
|
||||
progressMessage.textContent = status.message;
|
||||
|
||||
if (status.status === 'completed') {
|
||||
clearInterval(pollInterval);
|
||||
progressArea.classList.add('hidden');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = '<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg> Research starten';
|
||||
|
||||
if (status.topics && status.topics.length > 0) {
|
||||
topicsList.innerHTML = status.topics.map((topic, i) => `
|
||||
<div class="bg-brand-bg rounded-lg p-4 border border-brand-bg-light">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex-1">
|
||||
<span class="inline-block px-2 py-1 text-xs font-medium bg-brand-highlight/20 text-brand-highlight rounded mb-2">${topic.category || 'Topic'}</span>
|
||||
<h4 class="font-semibold text-white">${topic.title}</h4>
|
||||
${topic.angle ? `<p class="text-sm text-brand-highlight/80 mt-1">↳ ${topic.angle}</p>` : ''}
|
||||
${topic.hook_idea ? `<p class="text-sm text-gray-300 mt-2 italic border-l-2 border-brand-highlight/30 pl-2">"${topic.hook_idea.substring(0, 150)}..."</p>` : ''}
|
||||
<p class="text-gray-400 text-sm mt-2">${topic.fact ? topic.fact.substring(0, 200) + '...' : ''}</p>
|
||||
${topic.source ? `<p class="text-gray-500 text-xs mt-2">Quelle: ${topic.source}</p>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
} else {
|
||||
topicsList.innerHTML = '<p class="text-gray-400">Keine Topics gefunden.</p>';
|
||||
}
|
||||
} else if (status.status === 'error') {
|
||||
clearInterval(pollInterval);
|
||||
progressArea.classList.add('hidden');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = '<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg> Research starten';
|
||||
topicsList.innerHTML = `<p class="text-red-400">Fehler: ${status.message}</p>`;
|
||||
}
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
progressArea.classList.add('hidden');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = '<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg> Research starten';
|
||||
topicsList.innerHTML = `<p class="text-red-400">Fehler: ${error.message}</p>`;
|
||||
}
|
||||
});
|
||||
|
||||
// Load post types on page load
|
||||
loadPostTypes();
|
||||
</script>
|
||||
{% endblock %}
|
||||
142
src/web/templates/user/status.html
Normal file
142
src/web/templates/user/status.html
Normal file
@@ -0,0 +1,142 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Status - LinkedIn Posts{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Status</h1>
|
||||
<p class="text-gray-400">Übersicht über deinen Setup-Status</p>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="bg-red-900/50 border border-red-500 text-red-200 px-4 py-3 rounded-lg mb-6">
|
||||
<strong>Error:</strong> {{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if status %}
|
||||
<div class="card-bg rounded-xl border overflow-hidden">
|
||||
<!-- Header -->
|
||||
<div class="px-6 py-4 border-b border-brand-bg-light">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 rounded-full flex items-center justify-center overflow-hidden {{ 'bg-brand-highlight' if not profile_picture else '' }}">
|
||||
{% if profile_picture %}
|
||||
<img src="{{ profile_picture }}" alt="{{ customer.name }}" class="w-full h-full object-cover" loading="lazy" referrerpolicy="no-referrer">
|
||||
{% else %}
|
||||
<span class="text-brand-bg-dark font-bold text-lg">{{ customer.name[0] | upper }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-white text-lg">{{ customer.name }}</h3>
|
||||
<p class="text-sm text-gray-400">{{ customer.company_name or 'Kein Unternehmen' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
{% if status.ready_for_posts %}
|
||||
<span class="px-3 py-1.5 bg-green-600/30 text-green-300 rounded-lg text-sm font-medium flex items-center gap-2">
|
||||
<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="M5 13l4 4L19 7"/></svg>
|
||||
Bereit für Posts
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="px-3 py-1.5 bg-yellow-600/30 text-yellow-300 rounded-lg text-sm font-medium flex items-center gap-2">
|
||||
<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="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>
|
||||
Setup unvollständig
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Grid -->
|
||||
<div class="p-6">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<!-- Scraped Posts -->
|
||||
<div class="bg-brand-bg/30 rounded-lg p-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
{% if status.has_scraped_posts %}
|
||||
<svg class="w-5 h-5 text-green-500" 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>
|
||||
{% else %}
|
||||
<svg class="w-5 h-5 text-gray-500" 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>
|
||||
{% endif %}
|
||||
<span class="text-sm text-gray-400">Scraped Posts</span>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-white">{{ status.scraped_posts_count }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Profile Analysis -->
|
||||
<div class="bg-brand-bg/30 rounded-lg p-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
{% if status.has_profile_analysis %}
|
||||
<svg class="w-5 h-5 text-green-500" 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>
|
||||
{% else %}
|
||||
<svg class="w-5 h-5 text-gray-500" 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>
|
||||
{% endif %}
|
||||
<span class="text-sm text-gray-400">Profil Analyse</span>
|
||||
</div>
|
||||
<p class="text-lg font-semibold text-white">{{ 'Vorhanden' if status.has_profile_analysis else 'Fehlt' }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Research Topics -->
|
||||
<div class="bg-brand-bg/30 rounded-lg p-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
{% if status.research_count > 0 %}
|
||||
<svg class="w-5 h-5 text-green-500" 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>
|
||||
{% else %}
|
||||
<svg class="w-5 h-5 text-gray-500" 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>
|
||||
{% endif %}
|
||||
<span class="text-sm text-gray-400">Research Topics</span>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-white">{{ status.research_count }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Generated Posts -->
|
||||
<div class="bg-brand-bg/30 rounded-lg p-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<svg class="w-5 h-5 text-brand-highlight" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 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>
|
||||
<span class="text-sm text-gray-400">Generierte Posts</span>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-white">{{ status.posts_count }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Missing Items -->
|
||||
{% if status.missing_items %}
|
||||
<div class="bg-yellow-900/20 border border-yellow-600/50 rounded-lg p-4">
|
||||
<h4 class="font-medium text-yellow-300 mb-2 flex items-center gap-2">
|
||||
<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="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>
|
||||
Fehlende Elemente
|
||||
</h4>
|
||||
<ul class="space-y-1">
|
||||
{% for item in status.missing_items %}
|
||||
<li class="text-yellow-200/80 text-sm flex items-center gap-2">
|
||||
<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="M9 5l7 7-7 7"/></svg>
|
||||
{{ item }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="flex gap-3 mt-4">
|
||||
{% if status.research_count == 0 %}
|
||||
<a href="/research" class="px-4 py-2 bg-green-600 hover:bg-green-700 rounded-lg text-sm text-white transition-colors">
|
||||
Recherche starten
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if status.ready_for_posts %}
|
||||
<a href="/create" class="px-4 py-2 btn-primary rounded-lg text-sm transition-colors">
|
||||
Post erstellen
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card-bg rounded-xl border p-12 text-center">
|
||||
<svg class="w-16 h-16 text-gray-600 mx-auto mb-4" 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"/></svg>
|
||||
<h3 class="text-xl font-semibold text-white mb-2">Status nicht verfügbar</h3>
|
||||
<p class="text-gray-400 mb-6">Es konnte kein Status geladen werden.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
4
src/web/user/__init__.py
Normal file
4
src/web/user/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""User frontend module."""
|
||||
from src.web.user.routes import user_router
|
||||
|
||||
__all__ = ["user_router"]
|
||||
348
src/web/user/auth.py
Normal file
348
src/web/user/auth.py
Normal file
@@ -0,0 +1,348 @@
|
||||
"""User authentication with Supabase LinkedIn OAuth."""
|
||||
import re
|
||||
import secrets
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import Request, Response
|
||||
from loguru import logger
|
||||
|
||||
from src.config import settings
|
||||
from src.database import db
|
||||
|
||||
# Session management
|
||||
USER_SESSION_COOKIE = "linkedin_user_session"
|
||||
SESSION_SECRET = settings.session_secret or secrets.token_hex(32)
|
||||
|
||||
|
||||
def normalize_linkedin_url(url: str) -> str:
|
||||
"""Normalize LinkedIn URL for comparison.
|
||||
|
||||
Extracts the username/vanityName from various LinkedIn URL formats.
|
||||
"""
|
||||
if not url:
|
||||
return ""
|
||||
# Match linkedin.com/in/username with optional trailing slash or query params
|
||||
match = re.search(r'linkedin\.com/in/([^/?]+)', url.lower())
|
||||
if match:
|
||||
return match.group(1).rstrip('/')
|
||||
return url.lower().strip()
|
||||
|
||||
|
||||
async def get_customer_by_vanity_name(vanity_name: str) -> Optional[dict]:
|
||||
"""Find customer by LinkedIn vanityName.
|
||||
|
||||
Constructs the LinkedIn URL from vanityName and matches against
|
||||
Customer.linkedin_url (normalized).
|
||||
"""
|
||||
if not vanity_name:
|
||||
return None
|
||||
|
||||
normalized_vanity = normalize_linkedin_url(f"https://www.linkedin.com/in/{vanity_name}/")
|
||||
|
||||
# Get all customers and match
|
||||
customers = await db.list_customers()
|
||||
for customer in customers:
|
||||
customer_vanity = normalize_linkedin_url(customer.linkedin_url)
|
||||
if customer_vanity == normalized_vanity:
|
||||
return {
|
||||
"id": str(customer.id),
|
||||
"name": customer.name,
|
||||
"linkedin_url": customer.linkedin_url,
|
||||
"company_name": customer.company_name,
|
||||
"email": customer.email
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def get_customer_by_email(email: str) -> Optional[dict]:
|
||||
"""Find customer by email address.
|
||||
|
||||
Fallback matching when LinkedIn vanityName is not available.
|
||||
"""
|
||||
if not email:
|
||||
return None
|
||||
|
||||
email_lower = email.lower().strip()
|
||||
|
||||
# Get all customers and match by email
|
||||
customers = await db.list_customers()
|
||||
for customer in customers:
|
||||
if customer.email and customer.email.lower().strip() == email_lower:
|
||||
return {
|
||||
"id": str(customer.id),
|
||||
"name": customer.name,
|
||||
"linkedin_url": customer.linkedin_url,
|
||||
"company_name": customer.company_name,
|
||||
"email": customer.email
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def get_customer_by_name(name: str) -> Optional[dict]:
|
||||
"""Find customer by name.
|
||||
|
||||
Fallback matching when email is not available.
|
||||
Tries exact match first, then case-insensitive.
|
||||
"""
|
||||
if not name:
|
||||
return None
|
||||
|
||||
name_lower = name.lower().strip()
|
||||
|
||||
# Get all customers and match by name
|
||||
customers = await db.list_customers()
|
||||
|
||||
# First try exact match
|
||||
for customer in customers:
|
||||
if customer.name == name:
|
||||
return {
|
||||
"id": str(customer.id),
|
||||
"name": customer.name,
|
||||
"linkedin_url": customer.linkedin_url,
|
||||
"company_name": customer.company_name,
|
||||
"email": customer.email
|
||||
}
|
||||
|
||||
# Then try case-insensitive
|
||||
for customer in customers:
|
||||
if customer.name.lower().strip() == name_lower:
|
||||
return {
|
||||
"id": str(customer.id),
|
||||
"name": customer.name,
|
||||
"linkedin_url": customer.linkedin_url,
|
||||
"company_name": customer.company_name,
|
||||
"email": customer.email
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class UserSession:
|
||||
"""User session data."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
customer_id: str,
|
||||
customer_name: str,
|
||||
linkedin_vanity_name: str,
|
||||
linkedin_name: Optional[str] = None,
|
||||
linkedin_picture: Optional[str] = None,
|
||||
email: Optional[str] = None
|
||||
):
|
||||
self.customer_id = customer_id
|
||||
self.customer_name = customer_name
|
||||
self.linkedin_vanity_name = linkedin_vanity_name
|
||||
self.linkedin_name = linkedin_name
|
||||
self.linkedin_picture = linkedin_picture
|
||||
self.email = email
|
||||
|
||||
def to_cookie_value(self) -> str:
|
||||
"""Serialize session to cookie value."""
|
||||
import json
|
||||
import hashlib
|
||||
|
||||
data = {
|
||||
"customer_id": self.customer_id,
|
||||
"customer_name": self.customer_name,
|
||||
"linkedin_vanity_name": self.linkedin_vanity_name,
|
||||
"linkedin_name": self.linkedin_name,
|
||||
"linkedin_picture": self.linkedin_picture,
|
||||
"email": self.email
|
||||
}
|
||||
|
||||
# Create signed cookie value
|
||||
json_data = json.dumps(data)
|
||||
signature = hashlib.sha256(f"{json_data}{SESSION_SECRET}".encode()).hexdigest()[:16]
|
||||
|
||||
import base64
|
||||
encoded = base64.b64encode(json_data.encode()).decode()
|
||||
return f"{encoded}.{signature}"
|
||||
|
||||
@classmethod
|
||||
def from_cookie_value(cls, cookie_value: str) -> Optional["UserSession"]:
|
||||
"""Deserialize session from cookie value."""
|
||||
import json
|
||||
import hashlib
|
||||
import base64
|
||||
|
||||
try:
|
||||
parts = cookie_value.split(".")
|
||||
if len(parts) != 2:
|
||||
return None
|
||||
|
||||
encoded, signature = parts
|
||||
json_data = base64.b64decode(encoded.encode()).decode()
|
||||
|
||||
# Verify signature
|
||||
expected_sig = hashlib.sha256(f"{json_data}{SESSION_SECRET}".encode()).hexdigest()[:16]
|
||||
if signature != expected_sig:
|
||||
logger.warning("Invalid session signature")
|
||||
return None
|
||||
|
||||
data = json.loads(json_data)
|
||||
return cls(
|
||||
customer_id=data["customer_id"],
|
||||
customer_name=data["customer_name"],
|
||||
linkedin_vanity_name=data["linkedin_vanity_name"],
|
||||
linkedin_name=data.get("linkedin_name"),
|
||||
linkedin_picture=data.get("linkedin_picture"),
|
||||
email=data.get("email")
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to parse session cookie: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def get_user_session(request: Request) -> Optional[UserSession]:
|
||||
"""Get user session from request cookies."""
|
||||
cookie = request.cookies.get(USER_SESSION_COOKIE)
|
||||
if not cookie:
|
||||
return None
|
||||
return UserSession.from_cookie_value(cookie)
|
||||
|
||||
|
||||
def set_user_session(response: Response, session: UserSession) -> None:
|
||||
"""Set user session cookie."""
|
||||
response.set_cookie(
|
||||
key=USER_SESSION_COOKIE,
|
||||
value=session.to_cookie_value(),
|
||||
httponly=True,
|
||||
max_age=60 * 60 * 24 * 7, # 7 days
|
||||
samesite="lax"
|
||||
)
|
||||
|
||||
|
||||
def clear_user_session(response: Response) -> None:
|
||||
"""Clear user session cookie."""
|
||||
response.delete_cookie(USER_SESSION_COOKIE)
|
||||
|
||||
|
||||
async def handle_oauth_callback(
|
||||
access_token: str,
|
||||
refresh_token: Optional[str] = None
|
||||
) -> Optional[UserSession]:
|
||||
"""Handle OAuth callback from Supabase.
|
||||
|
||||
1. Get user info from Supabase using access token
|
||||
2. Extract LinkedIn vanityName from user metadata
|
||||
3. Match with Customer record
|
||||
4. Create session if match found
|
||||
|
||||
Returns UserSession if authorized, None if not.
|
||||
"""
|
||||
from supabase import create_client
|
||||
|
||||
try:
|
||||
# Create a new client with the user's access token
|
||||
supabase = create_client(settings.supabase_url, settings.supabase_key)
|
||||
|
||||
# Get user info using the access token
|
||||
user_response = supabase.auth.get_user(access_token)
|
||||
|
||||
if not user_response or not user_response.user:
|
||||
logger.error("Failed to get user from Supabase")
|
||||
return None
|
||||
|
||||
user = user_response.user
|
||||
user_metadata = user.user_metadata or {}
|
||||
|
||||
# Debug: Log full response
|
||||
import json
|
||||
logger.info(f"=== FULL OAUTH RESPONSE ===")
|
||||
logger.info(f"user.id: {user.id}")
|
||||
logger.info(f"user.email: {user.email}")
|
||||
logger.info(f"user.phone: {user.phone}")
|
||||
logger.info(f"user.app_metadata: {json.dumps(user.app_metadata, indent=2)}")
|
||||
logger.info(f"user.user_metadata: {json.dumps(user.user_metadata, indent=2)}")
|
||||
logger.info(f"--- Einzelne Felder ---")
|
||||
logger.info(f"given_name: {user_metadata.get('given_name')}")
|
||||
logger.info(f"family_name: {user_metadata.get('family_name')}")
|
||||
logger.info(f"name: {user_metadata.get('name')}")
|
||||
logger.info(f"email (metadata): {user_metadata.get('email')}")
|
||||
logger.info(f"picture: {user_metadata.get('picture')}")
|
||||
logger.info(f"sub: {user_metadata.get('sub')}")
|
||||
logger.info(f"provider_id: {user_metadata.get('provider_id')}")
|
||||
logger.info(f"=== END OAUTH RESPONSE ===")
|
||||
|
||||
# LinkedIn OIDC provides these fields
|
||||
vanity_name = user_metadata.get("vanityName") # LinkedIn username (often not provided)
|
||||
name = user_metadata.get("name")
|
||||
picture = user_metadata.get("picture")
|
||||
email = user.email
|
||||
|
||||
logger.info(f"OAuth callback for user: {name} (vanityName={vanity_name}, email={email})")
|
||||
|
||||
# Try to match with customer
|
||||
customer = None
|
||||
|
||||
# First try vanityName if available
|
||||
if vanity_name:
|
||||
customer = await get_customer_by_vanity_name(vanity_name)
|
||||
if customer:
|
||||
logger.info(f"Matched by vanityName: {vanity_name}")
|
||||
|
||||
# Fallback to email matching
|
||||
if not customer and email:
|
||||
customer = await get_customer_by_email(email)
|
||||
if customer:
|
||||
logger.info(f"Matched by email: {email}")
|
||||
|
||||
# Fallback to name matching
|
||||
if not customer and name:
|
||||
customer = await get_customer_by_name(name)
|
||||
if customer:
|
||||
logger.info(f"Matched by name: {name}")
|
||||
|
||||
if not customer:
|
||||
# Debug: List all customers to help diagnose
|
||||
all_customers = await db.list_customers()
|
||||
logger.warning(f"No customer found for LinkedIn user: {name} (email={email}, vanityName={vanity_name})")
|
||||
logger.warning(f"Available customers:")
|
||||
for c in all_customers:
|
||||
logger.warning(f" - {c.name}: email={c.email}, linkedin={c.linkedin_url}")
|
||||
return None
|
||||
|
||||
logger.info(f"User {name} matched with customer {customer['name']}")
|
||||
|
||||
# Use vanityName from OAuth or extract from customer's linkedin_url
|
||||
effective_vanity_name = vanity_name
|
||||
if not effective_vanity_name and customer.get("linkedin_url"):
|
||||
effective_vanity_name = normalize_linkedin_url(customer["linkedin_url"])
|
||||
|
||||
return UserSession(
|
||||
customer_id=customer["id"],
|
||||
customer_name=customer["name"],
|
||||
linkedin_vanity_name=effective_vanity_name or "",
|
||||
linkedin_name=name,
|
||||
linkedin_picture=picture,
|
||||
email=email
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"OAuth callback error: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def get_supabase_login_url(redirect_to: str) -> str:
|
||||
"""Generate Supabase OAuth login URL for LinkedIn.
|
||||
|
||||
Args:
|
||||
redirect_to: The URL to redirect to after OAuth (the callback endpoint)
|
||||
|
||||
Returns:
|
||||
The Supabase OAuth URL to redirect the user to
|
||||
"""
|
||||
from urllib.parse import urlencode
|
||||
|
||||
# Supabase OAuth endpoint
|
||||
base_url = f"{settings.supabase_url}/auth/v1/authorize"
|
||||
|
||||
params = {
|
||||
"provider": "linkedin_oidc",
|
||||
"redirect_to": redirect_to
|
||||
}
|
||||
|
||||
return f"{base_url}?{urlencode(params)}"
|
||||
464
src/web/user/routes.py
Normal file
464
src/web/user/routes.py
Normal file
@@ -0,0 +1,464 @@
|
||||
"""User frontend routes (LinkedIn OAuth protected)."""
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Request, Form, BackgroundTasks, HTTPException
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from pydantic import BaseModel
|
||||
from loguru import logger
|
||||
|
||||
from src.config import settings
|
||||
from src.database import db
|
||||
from src.orchestrator import orchestrator
|
||||
from src.web.user.auth import (
|
||||
get_user_session, set_user_session, clear_user_session,
|
||||
get_supabase_login_url, handle_oauth_callback, UserSession
|
||||
)
|
||||
|
||||
# Router for user frontend
|
||||
user_router = APIRouter(tags=["user"])
|
||||
|
||||
# Templates
|
||||
templates = Jinja2Templates(directory=Path(__file__).parent.parent / "templates" / "user")
|
||||
|
||||
# Store for progress updates
|
||||
progress_store = {}
|
||||
|
||||
|
||||
async def get_customer_profile_picture(customer_id: UUID) -> Optional[str]:
|
||||
"""Get profile picture URL from customer's LinkedIn posts."""
|
||||
linkedin_posts = await db.get_linkedin_posts(customer_id)
|
||||
for lp in linkedin_posts:
|
||||
if lp.raw_data and isinstance(lp.raw_data, dict):
|
||||
author = lp.raw_data.get("author", {})
|
||||
if author and isinstance(author, dict):
|
||||
profile_picture_url = author.get("profile_picture")
|
||||
if profile_picture_url:
|
||||
return profile_picture_url
|
||||
return None
|
||||
|
||||
|
||||
def require_user_session(request: Request) -> Optional[UserSession]:
|
||||
"""Check if user is authenticated, redirect to login if not."""
|
||||
session = get_user_session(request)
|
||||
if not session:
|
||||
return None
|
||||
return session
|
||||
|
||||
|
||||
# ==================== AUTH ROUTES ====================
|
||||
|
||||
@user_router.get("/login", response_class=HTMLResponse)
|
||||
async def login_page(request: Request, error: str = None):
|
||||
"""User login page with LinkedIn OAuth button."""
|
||||
# If already logged in, redirect to dashboard
|
||||
session = get_user_session(request)
|
||||
if session:
|
||||
return RedirectResponse(url="/", status_code=302)
|
||||
|
||||
return templates.TemplateResponse("login.html", {
|
||||
"request": request,
|
||||
"error": error
|
||||
})
|
||||
|
||||
|
||||
@user_router.get("/auth/linkedin")
|
||||
async def start_oauth(request: Request):
|
||||
"""Start LinkedIn OAuth flow via Supabase."""
|
||||
# Build callback URL
|
||||
callback_url = settings.supabase_redirect_url
|
||||
if not callback_url:
|
||||
# Fallback to constructing from request
|
||||
callback_url = str(request.url_for("oauth_callback"))
|
||||
|
||||
login_url = get_supabase_login_url(callback_url)
|
||||
return RedirectResponse(url=login_url, status_code=302)
|
||||
|
||||
|
||||
@user_router.get("/auth/callback")
|
||||
async def oauth_callback(
|
||||
request: Request,
|
||||
access_token: str = None,
|
||||
refresh_token: str = None,
|
||||
error: str = None,
|
||||
error_description: str = None
|
||||
):
|
||||
"""Handle OAuth callback from Supabase."""
|
||||
if error:
|
||||
logger.error(f"OAuth error: {error} - {error_description}")
|
||||
return RedirectResponse(url=f"/login?error={error}", status_code=302)
|
||||
|
||||
# Supabase returns tokens in URL hash, not query params
|
||||
# We need to handle this client-side and redirect back
|
||||
# Check if we have the tokens
|
||||
if not access_token:
|
||||
# Render a page that extracts hash params and redirects
|
||||
return templates.TemplateResponse("auth_callback.html", {
|
||||
"request": request
|
||||
})
|
||||
|
||||
# We have the tokens, try to authenticate
|
||||
session = await handle_oauth_callback(access_token, refresh_token)
|
||||
|
||||
if not session:
|
||||
return RedirectResponse(url="/not-authorized", status_code=302)
|
||||
|
||||
# Success - set session and redirect to dashboard
|
||||
response = RedirectResponse(url="/", status_code=302)
|
||||
set_user_session(response, session)
|
||||
return response
|
||||
|
||||
|
||||
@user_router.get("/logout")
|
||||
async def logout(request: Request):
|
||||
"""Log out user."""
|
||||
response = RedirectResponse(url="/login", status_code=302)
|
||||
clear_user_session(response)
|
||||
return response
|
||||
|
||||
|
||||
@user_router.get("/not-authorized", response_class=HTMLResponse)
|
||||
async def not_authorized_page(request: Request):
|
||||
"""Page shown when user's LinkedIn profile doesn't match any customer."""
|
||||
return templates.TemplateResponse("not_authorized.html", {
|
||||
"request": request
|
||||
})
|
||||
|
||||
|
||||
# ==================== PROTECTED PAGES ====================
|
||||
|
||||
@user_router.get("/", response_class=HTMLResponse)
|
||||
async def dashboard(request: Request):
|
||||
"""User dashboard - shows only their own stats."""
|
||||
session = require_user_session(request)
|
||||
if not session:
|
||||
return RedirectResponse(url="/login", status_code=302)
|
||||
|
||||
try:
|
||||
customer_id = UUID(session.customer_id)
|
||||
customer = await db.get_customer(customer_id)
|
||||
posts = await db.get_generated_posts(customer_id)
|
||||
profile_picture = session.linkedin_picture or await get_customer_profile_picture(customer_id)
|
||||
|
||||
return templates.TemplateResponse("dashboard.html", {
|
||||
"request": request,
|
||||
"page": "home",
|
||||
"session": session,
|
||||
"customer": customer,
|
||||
"total_posts": len(posts),
|
||||
"profile_picture": profile_picture
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading dashboard: {e}")
|
||||
return templates.TemplateResponse("dashboard.html", {
|
||||
"request": request,
|
||||
"page": "home",
|
||||
"session": session,
|
||||
"error": str(e)
|
||||
})
|
||||
|
||||
|
||||
@user_router.get("/posts", response_class=HTMLResponse)
|
||||
async def posts_page(request: Request):
|
||||
"""View user's own posts."""
|
||||
session = require_user_session(request)
|
||||
if not session:
|
||||
return RedirectResponse(url="/login", status_code=302)
|
||||
|
||||
try:
|
||||
customer_id = UUID(session.customer_id)
|
||||
customer = await db.get_customer(customer_id)
|
||||
posts = await db.get_generated_posts(customer_id)
|
||||
profile_picture = session.linkedin_picture or await get_customer_profile_picture(customer_id)
|
||||
|
||||
return templates.TemplateResponse("posts.html", {
|
||||
"request": request,
|
||||
"page": "posts",
|
||||
"session": session,
|
||||
"customer": customer,
|
||||
"posts": posts,
|
||||
"total_posts": len(posts),
|
||||
"profile_picture": profile_picture
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading posts: {e}")
|
||||
return templates.TemplateResponse("posts.html", {
|
||||
"request": request,
|
||||
"page": "posts",
|
||||
"session": session,
|
||||
"posts": [],
|
||||
"total_posts": 0,
|
||||
"error": str(e)
|
||||
})
|
||||
|
||||
|
||||
@user_router.get("/posts/{post_id}", response_class=HTMLResponse)
|
||||
async def post_detail_page(request: Request, post_id: str):
|
||||
"""Detailed view of a single post."""
|
||||
session = require_user_session(request)
|
||||
if not session:
|
||||
return RedirectResponse(url="/login", status_code=302)
|
||||
|
||||
try:
|
||||
post = await db.get_generated_post(UUID(post_id))
|
||||
if not post:
|
||||
return RedirectResponse(url="/posts", status_code=302)
|
||||
|
||||
# Verify user owns this post
|
||||
if str(post.customer_id) != session.customer_id:
|
||||
return RedirectResponse(url="/posts", status_code=302)
|
||||
|
||||
customer = await db.get_customer(post.customer_id)
|
||||
linkedin_posts = await db.get_linkedin_posts(post.customer_id)
|
||||
reference_posts = [p.post_text for p in linkedin_posts if p.post_text and len(p.post_text) > 100][:10]
|
||||
|
||||
profile_picture_url = session.linkedin_picture
|
||||
if not profile_picture_url:
|
||||
for lp in linkedin_posts:
|
||||
if lp.raw_data and isinstance(lp.raw_data, dict):
|
||||
author = lp.raw_data.get("author", {})
|
||||
if author and isinstance(author, dict):
|
||||
profile_picture_url = author.get("profile_picture")
|
||||
if profile_picture_url:
|
||||
break
|
||||
|
||||
profile_analysis_record = await db.get_profile_analysis(post.customer_id)
|
||||
profile_analysis = profile_analysis_record.full_analysis if profile_analysis_record else None
|
||||
|
||||
post_type = None
|
||||
post_type_analysis = None
|
||||
if post.post_type_id:
|
||||
post_type = await db.get_post_type(post.post_type_id)
|
||||
if post_type and post_type.analysis:
|
||||
post_type_analysis = post_type.analysis
|
||||
|
||||
final_feedback = None
|
||||
if post.critic_feedback and len(post.critic_feedback) > 0:
|
||||
final_feedback = post.critic_feedback[-1]
|
||||
|
||||
return templates.TemplateResponse("post_detail.html", {
|
||||
"request": request,
|
||||
"page": "posts",
|
||||
"session": session,
|
||||
"post": post,
|
||||
"customer": customer,
|
||||
"reference_posts": reference_posts,
|
||||
"profile_analysis": profile_analysis,
|
||||
"post_type": post_type,
|
||||
"post_type_analysis": post_type_analysis,
|
||||
"final_feedback": final_feedback,
|
||||
"profile_picture_url": profile_picture_url
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading post detail: {e}")
|
||||
return RedirectResponse(url="/posts", status_code=302)
|
||||
|
||||
|
||||
@user_router.get("/research", response_class=HTMLResponse)
|
||||
async def research_page(request: Request):
|
||||
"""Research topics page - no customer dropdown needed."""
|
||||
session = require_user_session(request)
|
||||
if not session:
|
||||
return RedirectResponse(url="/login", status_code=302)
|
||||
|
||||
return templates.TemplateResponse("research.html", {
|
||||
"request": request,
|
||||
"page": "research",
|
||||
"session": session,
|
||||
"customer_id": session.customer_id
|
||||
})
|
||||
|
||||
|
||||
@user_router.get("/create", response_class=HTMLResponse)
|
||||
async def create_post_page(request: Request):
|
||||
"""Create post page - no customer dropdown needed."""
|
||||
session = require_user_session(request)
|
||||
if not session:
|
||||
return RedirectResponse(url="/login", status_code=302)
|
||||
|
||||
return templates.TemplateResponse("create_post.html", {
|
||||
"request": request,
|
||||
"page": "create",
|
||||
"session": session,
|
||||
"customer_id": session.customer_id
|
||||
})
|
||||
|
||||
|
||||
@user_router.get("/status", response_class=HTMLResponse)
|
||||
async def status_page(request: Request):
|
||||
"""User's status page."""
|
||||
session = require_user_session(request)
|
||||
if not session:
|
||||
return RedirectResponse(url="/login", status_code=302)
|
||||
|
||||
try:
|
||||
customer_id = UUID(session.customer_id)
|
||||
customer = await db.get_customer(customer_id)
|
||||
status = await orchestrator.get_customer_status(customer_id)
|
||||
profile_picture = session.linkedin_picture or await get_customer_profile_picture(customer_id)
|
||||
|
||||
return templates.TemplateResponse("status.html", {
|
||||
"request": request,
|
||||
"page": "status",
|
||||
"session": session,
|
||||
"customer": customer,
|
||||
"status": status,
|
||||
"profile_picture": profile_picture
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading status: {e}")
|
||||
return templates.TemplateResponse("status.html", {
|
||||
"request": request,
|
||||
"page": "status",
|
||||
"session": session,
|
||||
"error": str(e)
|
||||
})
|
||||
|
||||
|
||||
# ==================== API ENDPOINTS ====================
|
||||
|
||||
@user_router.get("/api/post-types")
|
||||
async def get_post_types(request: Request):
|
||||
"""Get post types for the logged-in user's customer."""
|
||||
session = require_user_session(request)
|
||||
if not session:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
try:
|
||||
post_types = await db.get_post_types(UUID(session.customer_id))
|
||||
return {
|
||||
"post_types": [
|
||||
{
|
||||
"id": str(pt.id),
|
||||
"name": pt.name,
|
||||
"description": pt.description,
|
||||
"has_analysis": pt.analysis is not None,
|
||||
"analyzed_post_count": pt.analyzed_post_count,
|
||||
}
|
||||
for pt in post_types
|
||||
]
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading post types: {e}")
|
||||
return {"post_types": [], "error": str(e)}
|
||||
|
||||
|
||||
@user_router.get("/api/topics")
|
||||
async def get_topics(request: Request, post_type_id: str = None):
|
||||
"""Get research topics for the logged-in user."""
|
||||
session = require_user_session(request)
|
||||
if not session:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
try:
|
||||
customer_id = UUID(session.customer_id)
|
||||
if post_type_id:
|
||||
all_research = await db.get_all_research(customer_id, UUID(post_type_id))
|
||||
else:
|
||||
all_research = await db.get_all_research(customer_id)
|
||||
|
||||
# Get used topics
|
||||
generated_posts = await db.get_generated_posts(customer_id)
|
||||
used_topic_titles = set()
|
||||
for post in generated_posts:
|
||||
if post.topic_title:
|
||||
used_topic_titles.add(post.topic_title.lower().strip())
|
||||
|
||||
all_topics = []
|
||||
for research in all_research:
|
||||
if research.suggested_topics:
|
||||
for topic in research.suggested_topics:
|
||||
topic_title = topic.get("title", "").lower().strip()
|
||||
if topic_title in used_topic_titles:
|
||||
continue
|
||||
topic["research_id"] = str(research.id)
|
||||
topic["target_post_type_id"] = str(research.target_post_type_id) if research.target_post_type_id else None
|
||||
all_topics.append(topic)
|
||||
|
||||
return {"topics": all_topics, "used_count": len(used_topic_titles), "available_count": len(all_topics)}
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading topics: {e}")
|
||||
return {"topics": [], "error": str(e)}
|
||||
|
||||
|
||||
@user_router.get("/api/tasks/{task_id}")
|
||||
async def get_task_status(task_id: str):
|
||||
"""Get task progress."""
|
||||
return progress_store.get(task_id, {"status": "unknown", "message": "Task not found"})
|
||||
|
||||
|
||||
@user_router.post("/api/research")
|
||||
async def start_research(request: Request, background_tasks: BackgroundTasks, post_type_id: str = Form(None)):
|
||||
"""Start research for the logged-in user."""
|
||||
session = require_user_session(request)
|
||||
if not session:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
customer_id = session.customer_id
|
||||
task_id = f"research_{customer_id}_{asyncio.get_event_loop().time()}"
|
||||
progress_store[task_id] = {"status": "starting", "message": "Starte Recherche...", "progress": 0}
|
||||
|
||||
async def run_research():
|
||||
try:
|
||||
def progress_callback(message: str, step: int, total: int):
|
||||
progress_store[task_id] = {"status": "running", "message": message, "progress": int((step / total) * 100)}
|
||||
|
||||
topics = await orchestrator.research_new_topics(
|
||||
UUID(customer_id),
|
||||
progress_callback=progress_callback,
|
||||
post_type_id=UUID(post_type_id) if post_type_id else None
|
||||
)
|
||||
progress_store[task_id] = {"status": "completed", "message": f"{len(topics)} Topics gefunden!", "progress": 100, "topics": topics}
|
||||
except Exception as e:
|
||||
logger.exception(f"Research failed: {e}")
|
||||
progress_store[task_id] = {"status": "error", "message": str(e), "progress": 0}
|
||||
|
||||
background_tasks.add_task(run_research)
|
||||
return {"task_id": task_id}
|
||||
|
||||
|
||||
@user_router.post("/api/posts")
|
||||
async def create_post(request: Request, background_tasks: BackgroundTasks, topic_json: str = Form(...), post_type_id: str = Form(None)):
|
||||
"""Create a new post for the logged-in user."""
|
||||
session = require_user_session(request)
|
||||
if not session:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
customer_id = session.customer_id
|
||||
task_id = f"post_{customer_id}_{asyncio.get_event_loop().time()}"
|
||||
progress_store[task_id] = {"status": "starting", "message": "Starte Post-Erstellung...", "progress": 0}
|
||||
topic = json.loads(topic_json)
|
||||
|
||||
async def run_create_post():
|
||||
try:
|
||||
def progress_callback(message: str, iteration: int, max_iterations: int, score: int = None, versions: list = None, feedback_list: list = None):
|
||||
progress = int((iteration / max_iterations) * 100) if iteration > 0 else 5
|
||||
score_text = f" (Score: {score}/100)" if score else ""
|
||||
progress_store[task_id] = {
|
||||
"status": "running", "message": f"{message}{score_text}", "progress": progress,
|
||||
"iteration": iteration, "max_iterations": max_iterations,
|
||||
"versions": versions or [], "feedback_list": feedback_list or []
|
||||
}
|
||||
|
||||
result = await orchestrator.create_post(
|
||||
customer_id=UUID(customer_id), topic=topic, max_iterations=3,
|
||||
progress_callback=progress_callback,
|
||||
post_type_id=UUID(post_type_id) if post_type_id else None
|
||||
)
|
||||
progress_store[task_id] = {
|
||||
"status": "completed", "message": "Post erstellt!", "progress": 100,
|
||||
"result": {
|
||||
"post_id": str(result["post_id"]), "final_post": result["final_post"],
|
||||
"iterations": result["iterations"], "final_score": result["final_score"], "approved": result["approved"]
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
logger.exception(f"Post creation failed: {e}")
|
||||
progress_store[task_id] = {"status": "error", "message": str(e), "progress": 0}
|
||||
|
||||
background_tasks.add_task(run_create_post)
|
||||
return {"task_id": task_id}
|
||||
Reference in New Issue
Block a user