aktueller stand

This commit is contained in:
2026-02-03 12:48:43 +01:00
parent e1ecd1a38c
commit b50594dbfa
77 changed files with 19139 additions and 0 deletions

51
.dockerignore Normal file
View File

@@ -0,0 +1,51 @@
# Git
.git
.gitignore
# Environment files (secrets!)
.env
.env.local
.env.*.local
# Python
__pycache__
*.py[cod]
*$py.class
*.so
.Python
.venv
venv/
ENV/
# IDE
.idea/
.vscode/
*.swp
*.swo
# Logs
*.log
logs/
# Test
.pytest_cache/
.coverage
htmlcov/
# Build
build/
dist/
*.egg-info/
# Docker
Dockerfile
docker-compose*.yml
.dockerignore
# Documentation
*.md
!requirements.txt
# OS
.DS_Store
Thumbs.db

83
.env.example Normal file
View File

@@ -0,0 +1,83 @@
# ===========================================
# LinkedIn Post Creation System - Environment
# ===========================================
# Web Interface Password (leave empty to disable auth)
WEB_PASSWORD=your-secure-password-here
SESSION_SECRET=optional-random-string-for-session-security
# ===========================================
# API Keys
# ===========================================
# OpenAI API Key (required for post generation)
OPENAI_API_KEY=sk-your-openai-key
# Perplexity API Key (required for research)
PERPLEXITY_API_KEY=pplx-your-perplexity-key
# Apify API Key (required for LinkedIn scraping)
APIFY_API_KEY=apify_api_your-apify-key
# ===========================================
# Supabase Database
# ===========================================
SUPABASE_URL=https://your-project.supabase.co
SUPABASE_KEY=your-supabase-anon-key
# ===========================================
# Optional Settings
# ===========================================
# LinkedIn Scraping (Apify Actor)
APIFY_ACTOR_ID=apimaestro~linkedin-profile-posts
# Development
DEBUG=false
LOG_LEVEL=INFO
# ===========================================
# Email Settings (for sending posts)
# ===========================================
# SMTP Server Configuration
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your-email@gmail.com
SMTP_PASSWORD=your-app-password
SMTP_FROM_NAME=LinkedIn Post System
# Default recipient email (can be overridden in UI)
EMAIL_DEFAULT_RECIPIENT=
# ===========================================
# Writer Features (Advanced)
# ===========================================
# Multi-Draft: Generate multiple drafts and select the best one
# Uses ~2x more API tokens but often produces better results on first try
WRITER_MULTI_DRAFT_ENABLED=true
WRITER_MULTI_DRAFT_COUNT=3
# Semantic Matching: Select example posts based on topic similarity
# instead of random selection (no extra API cost)
WRITER_SEMANTIC_MATCHING_ENABLED=true
# Learn from Feedback: Analyze recurring critic feedback from past posts
# and include lessons learned in the writer prompt (no extra API cost)
WRITER_LEARN_FROM_FEEDBACK=true
WRITER_FEEDBACK_HISTORY_COUNT=10
# ===========================================
# User Frontend (LinkedIn OAuth)
# ===========================================
# Enable user frontend with LinkedIn OAuth login
# When enabled, / shows user login, /admin/* shows admin panel
USER_FRONTEND_ENABLED=true
# OAuth callback URL (must match Supabase settings)
# Local: http://localhost:8000/auth/callback
# Production: https://your-domain.com/auth/callback
SUPABASE_REDIRECT_URL=http://localhost:8000/auth/callback

57
.gitignore vendored Normal file
View File

@@ -0,0 +1,57 @@
# Environment
.env
.venv/
venv/
ENV/
env/
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# IDEs
.vscode/
.idea/
*.swp
*.swo
*~
# Logs
logs/
*.log
# OS
.DS_Store
Thumbs.db
# Database
*.db
*.sqlite
*.sqlite3
# Temporary files
*.tmp
*.bak
*.cache
# Data files (optional - depends on your needs)
data/secrets.json
data/credentials.json

388
DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,388 @@
# Deployment Guide - LinkedIn Post Creation System
Diese Anleitung erklärt, wie du die LinkedIn Post App auf deinem Server mit Docker deployen kannst.
## Voraussetzungen
- Ein Server (VPS/Cloud) mit:
- Ubuntu 20.04+ oder Debian 11+
- Mindestens 1GB RAM
- Docker & Docker Compose installiert
- Domain (optional, für HTTPS)
- API Keys:
- OpenAI API Key
- Perplexity API Key
- Apify API Key
- Supabase URL & Key
---
## Schritt 1: Server vorbereiten
### Docker installieren (falls nicht vorhanden)
```bash
# System aktualisieren
sudo apt update && sudo apt upgrade -y
# Docker installieren
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
# Docker Compose installieren
sudo apt install docker-compose-plugin -y
# Aktuellen User zur Docker-Gruppe hinzufügen
sudo usermod -aG docker $USER
# Neu einloggen oder:
newgrp docker
# Testen
docker --version
docker compose version
```
---
## Schritt 2: Projekt auf den Server laden
### Option A: Mit Git (empfohlen)
```bash
# Repository klonen
git clone https://github.com/dein-username/LinkedInWorkflow.git
cd LinkedInWorkflow
```
### Option B: Mit SCP (von deinem lokalen Rechner)
```bash
# Auf deinem lokalen Rechner:
scp -r /pfad/zu/LinkedInWorkflow user@dein-server:/home/user/
```
### Option C: Mit rsync
```bash
# Auf deinem lokalen Rechner:
rsync -avz --exclude '.env' --exclude '__pycache__' --exclude '.git' \
/pfad/zu/LinkedInWorkflow/ user@dein-server:/home/user/LinkedInWorkflow/
```
---
## Schritt 3: Umgebungsvariablen konfigurieren
```bash
cd LinkedInWorkflow
# .env Datei aus Vorlage erstellen
cp .env.example .env
# .env bearbeiten
nano .env
```
### Wichtige Einstellungen in der `.env`:
```env
# Web-Passwort (UNBEDINGT ÄNDERN!)
WEB_PASSWORD=dein-sicheres-passwort-hier
# API Keys (deine echten Keys eintragen)
OPENAI_API_KEY=sk-...
PERPLEXITY_API_KEY=pplx-...
APIFY_API_KEY=apify_api_...
# Supabase
SUPABASE_URL=https://dein-projekt.supabase.co
SUPABASE_KEY=dein-supabase-key
# Production Settings
DEBUG=false
LOG_LEVEL=INFO
```
**Wichtig:** Die `.env` Datei sollte NIEMALS committed werden!
---
## Schritt 4: Docker Container starten
```bash
# Im Projektverzeichnis:
cd LinkedInWorkflow
# Container bauen und starten
docker compose up -d --build
# Logs ansehen
docker compose logs -f
# Status prüfen
docker compose ps
```
Die App ist jetzt unter `http://dein-server:8000` erreichbar.
---
## Schritt 5: Firewall konfigurieren (optional aber empfohlen)
```bash
# UFW installieren (falls nicht vorhanden)
sudo apt install ufw -y
# SSH erlauben (WICHTIG - sonst sperrst du dich aus!)
sudo ufw allow ssh
# Port 8000 erlauben
sudo ufw allow 8000
# Firewall aktivieren
sudo ufw enable
# Status prüfen
sudo ufw status
```
---
## Schritt 6: Reverse Proxy mit Nginx & SSL (empfohlen für Production)
### Nginx installieren
```bash
sudo apt install nginx -y
```
### Nginx Konfiguration erstellen
```bash
sudo nano /etc/nginx/sites-available/linkedin-posts
```
Inhalt:
```nginx
server {
listen 80;
server_name deine-domain.de;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 86400;
}
}
```
### Nginx aktivieren
```bash
# Symlink erstellen
sudo ln -s /etc/nginx/sites-available/linkedin-posts /etc/nginx/sites-enabled/
# Default-Site entfernen
sudo rm /etc/nginx/sites-enabled/default
# Konfiguration testen
sudo nginx -t
# Nginx neu starten
sudo systemctl restart nginx
```
### SSL mit Let's Encrypt (kostenlos)
```bash
# Certbot installieren
sudo apt install certbot python3-certbot-nginx -y
# SSL-Zertifikat beantragen
sudo certbot --nginx -d deine-domain.de
# Auto-Renewal testen
sudo certbot renew --dry-run
```
---
## Nützliche Befehle
### Container Management
```bash
# Container stoppen
docker compose down
# Container neu starten
docker compose restart
# Container neu bauen (nach Code-Änderungen)
docker compose up -d --build
# In Container einloggen
docker compose exec linkedin-posts bash
# Logs ansehen (live)
docker compose logs -f
# Logs der letzten 100 Zeilen
docker compose logs --tail=100
```
### Updates einspielen
```bash
# Code aktualisieren (mit Git)
git pull
# Container neu bauen
docker compose up -d --build
```
### Backup
```bash
# .env sichern (enthält alle Secrets!)
cp .env .env.backup
# Alle Daten sind in Supabase - kein lokales Backup nötig
```
---
## Troubleshooting
### Container startet nicht
```bash
# Logs ansehen
docker compose logs linkedin-posts
# Container-Status prüfen
docker compose ps -a
# Neustart erzwingen
docker compose down && docker compose up -d --build
```
### Port bereits belegt
```bash
# Prüfen was auf Port 8000 läuft
sudo lsof -i :8000
# Prozess beenden
sudo kill -9 <PID>
```
### Keine Verbindung zu Supabase
1. Prüfe ob SUPABASE_URL und SUPABASE_KEY korrekt sind
2. Prüfe ob der Server ausgehende Verbindungen erlaubt
3. Teste: `curl -I https://dein-projekt.supabase.co`
### Passwort vergessen
```bash
# .env bearbeiten
nano .env
# WEB_PASSWORD ändern
# Container neu starten
docker compose restart
```
---
## Sicherheitsempfehlungen
1. **Starkes Passwort verwenden** - Mindestens 16 Zeichen, Sonderzeichen
2. **HTTPS aktivieren** - Mit Nginx + Let's Encrypt (siehe Schritt 6)
3. **Firewall konfigurieren** - Nur nötige Ports öffnen
4. **Server aktuell halten** - `sudo apt update && sudo apt upgrade`
5. **Docker aktuell halten** - `sudo apt upgrade docker-ce`
6. **Keine API Keys committen** - .env in .gitignore
---
## Monitoring (optional)
### Einfaches Health-Check Script
```bash
# health-check.sh erstellen
cat > health-check.sh << 'EOF'
#!/bin/bash
if curl -s -o /dev/null -w "%{http_code}" http://localhost:8000/login | grep -q "200"; then
echo "$(date): OK"
else
echo "$(date): FEHLER - Neustart..."
docker compose restart
fi
EOF
chmod +x health-check.sh
# Als Cron-Job (alle 5 Minuten)
(crontab -l 2>/dev/null; echo "*/5 * * * * /home/user/LinkedInWorkflow/health-check.sh >> /var/log/linkedin-health.log 2>&1") | crontab -
```
---
## Architektur
```
┌─────────────────────────────────────────────────────────┐
│ Internet │
└─────────────────────────┬───────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Nginx (Port 80/443) │
│ - SSL Termination │
│ - Reverse Proxy │
└─────────────────────────┬───────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Docker Container (Port 8000) │
│ │
│ ┌────────────────────────────────────────────────────┐ │
│ │ FastAPI Application │ │
│ │ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌────────────┐ │ │
│ │ │ Web UI │ │ API │ │ Agents │ │ │
│ │ │ (Jinja2) │ │ Endpoints │ │ (AI Logic) │ │ │
│ │ └─────────────┘ └─────────────┘ └────────────┘ │ │
│ └────────────────────────────────────────────────────┘ │
└─────────────────────────┬───────────────────────────────┘
┌───────────────┼───────────────┐
│ │ │
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Supabase │ │ OpenAI │ │ Perplexity│
│ DB │ │ API │ │ API │
└──────────┘ └──────────┘ └──────────┘
```
---
## Support
Bei Problemen:
1. Logs prüfen: `docker compose logs -f`
2. GitHub Issues öffnen
3. Container neu bauen: `docker compose up -d --build`

39
Dockerfile Normal file
View File

@@ -0,0 +1,39 @@
# LinkedIn Post Creation System - Docker Image
FROM python:3.11-slim
# Set environment variables
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV PYTHONPATH=/app
# Set work directory
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements first for better caching
COPY requirements.txt .
# Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
# Create non-root user for security
RUN useradd --create-home --shell /bin/bash appuser && \
chown -R appuser:appuser /app
USER appuser
# Expose port
EXPOSE 8000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD python -c "import httpx; httpx.get('http://localhost:8000/login', timeout=5)" || exit 1
# Run the application
CMD ["python", "-m", "uvicorn", "src.web.app:app", "--host", "0.0.0.0", "--port", "8000"]

224
config/supabase_schema.sql Normal file
View File

@@ -0,0 +1,224 @@
-- LinkedIn Workflow Database Schema for Supabase
-- Enable UUID extension
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Customers/Clients Table
CREATE TABLE IF NOT EXISTS customers (
id UUID PRIMARY wKEY DEFAULT uuid_generate_v4(),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Basic Info
name TEXT NOT NULL,
email TEXT,
company_name TEXT,
-- LinkedIn Profile
linkedin_url TEXT NOT NULL UNIQUE,
-- Metadata
metadata JSONB DEFAULT '{}'::JSONB
);
-- LinkedIn Profiles Table (scraped data)
CREATE TABLE IF NOT EXISTS linkedin_profiles (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
scraped_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Profile Data
profile_data JSONB NOT NULL,
-- Extracted Information
name TEXT,
headline TEXT,
summary TEXT,
location TEXT,
industry TEXT,
UNIQUE(customer_id)
);
-- LinkedIn Posts Table (scraped posts)
CREATE TABLE IF NOT EXISTS linkedin_posts (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
scraped_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Post Data
post_url TEXT,
post_text TEXT NOT NULL,
post_date TIMESTAMP WITH TIME ZONE,
likes INTEGER DEFAULT 0,
comments INTEGER DEFAULT 0,
shares INTEGER DEFAULT 0,
-- Raw Data
raw_data JSONB,
UNIQUE(customer_id, post_url)
);
-- Topics Table (extracted from posts)
CREATE TABLE IF NOT EXISTS topics (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Topic Info
title TEXT NOT NULL,
description TEXT,
category TEXT,
-- AI Extraction
extracted_from_post_id UUID REFERENCES linkedin_posts(id),
extraction_confidence FLOAT,
-- Status
is_used BOOLEAN DEFAULT FALSE,
used_at TIMESTAMP WITH TIME ZONE
);
-- Profile Analysis Table (AI-generated insights)
CREATE TABLE IF NOT EXISTS profile_analyses (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Analysis Results
writing_style JSONB NOT NULL,
tone_analysis JSONB NOT NULL,
topic_patterns JSONB NOT NULL,
audience_insights JSONB NOT NULL,
-- Full Analysis
full_analysis JSONB NOT NULL,
UNIQUE(customer_id)
);
-- Research Results Table
CREATE TABLE IF NOT EXISTS research_results (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Research Data
query TEXT NOT NULL,
results JSONB NOT NULL,
-- Topic Suggestions
suggested_topics JSONB NOT NULL,
-- Metadata
source TEXT DEFAULT 'perplexity'
);
-- Generated Posts Table
CREATE TABLE IF NOT EXISTS generated_posts (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Topic
topic_id UUID REFERENCES topics(id),
topic_title TEXT NOT NULL,
-- Post Content
post_content TEXT NOT NULL,
-- Generation Metadata
iterations INTEGER DEFAULT 0,
writer_versions JSONB DEFAULT '[]'::JSONB,
critic_feedback JSONB DEFAULT '[]'::JSONB,
-- Status
status TEXT DEFAULT 'draft' CHECK (status IN ('draft', 'approved', 'published', 'rejected')),
approved_at TIMESTAMP WITH TIME ZONE,
published_at TIMESTAMP WITH TIME ZONE
);
-- Create Indexes
CREATE INDEX idx_customers_linkedin_url ON customers(linkedin_url);
CREATE INDEX idx_linkedin_profiles_customer_id ON linkedin_profiles(customer_id);
CREATE INDEX idx_linkedin_posts_customer_id ON linkedin_posts(customer_id);
CREATE INDEX idx_topics_customer_id ON topics(customer_id);
CREATE INDEX idx_topics_is_used ON topics(is_used);
CREATE INDEX idx_profile_analyses_customer_id ON profile_analyses(customer_id);
CREATE INDEX idx_research_results_customer_id ON research_results(customer_id);
CREATE INDEX idx_generated_posts_customer_id ON generated_posts(customer_id);
CREATE INDEX idx_generated_posts_status ON generated_posts(status);
-- Post Types Table (for categorizing posts by type)
CREATE TABLE IF NOT EXISTS post_types (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Type Definition
name TEXT NOT NULL,
description TEXT,
identifying_hashtags TEXT[] DEFAULT '{}',
identifying_keywords TEXT[] DEFAULT '{}',
semantic_properties JSONB DEFAULT '{}'::JSONB,
-- Analysis Results (generated after classification)
analysis JSONB,
analysis_generated_at TIMESTAMP WITH TIME ZONE,
analyzed_post_count INTEGER DEFAULT 0,
-- Status
is_active BOOLEAN DEFAULT TRUE,
UNIQUE(customer_id, name)
);
-- Add post_type_id to linkedin_posts
ALTER TABLE linkedin_posts
ADD COLUMN IF NOT EXISTS post_type_id UUID REFERENCES post_types(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS classification_method TEXT,
ADD COLUMN IF NOT EXISTS classification_confidence FLOAT;
-- Add target_post_type_id to topics
ALTER TABLE topics
ADD COLUMN IF NOT EXISTS target_post_type_id UUID REFERENCES post_types(id) ON DELETE SET NULL;
-- Add target_post_type_id to research_results
ALTER TABLE research_results
ADD COLUMN IF NOT EXISTS target_post_type_id UUID REFERENCES post_types(id) ON DELETE SET NULL;
-- Add post_type_id to generated_posts
ALTER TABLE generated_posts
ADD COLUMN IF NOT EXISTS post_type_id UUID REFERENCES post_types(id) ON DELETE SET NULL;
-- Create indexes for post_types
CREATE INDEX IF NOT EXISTS idx_post_types_customer_id ON post_types(customer_id);
CREATE INDEX IF NOT EXISTS idx_post_types_is_active ON post_types(is_active);
CREATE INDEX IF NOT EXISTS idx_linkedin_posts_post_type_id ON linkedin_posts(post_type_id);
CREATE INDEX IF NOT EXISTS idx_topics_target_post_type_id ON topics(target_post_type_id);
CREATE INDEX IF NOT EXISTS idx_research_results_target_post_type_id ON research_results(target_post_type_id);
CREATE INDEX IF NOT EXISTS idx_generated_posts_post_type_id ON generated_posts(post_type_id);
-- Create updated_at trigger function
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Add trigger to customers table
CREATE TRIGGER update_customers_updated_at
BEFORE UPDATE ON customers
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-- Add trigger to post_types table
DROP TRIGGER IF EXISTS update_post_types_updated_at ON post_types;
CREATE TRIGGER update_post_types_updated_at
BEFORE UPDATE ON post_types
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();

130
data Normal file
View File

@@ -0,0 +1,130 @@
return {
"company_name": "MAKE IT MATTER",
"persona":
Christina Hildebrandt, Co-Founder MAKE IT MATTER I Kommunikationsberatung, Interimsmanagement, PR & LinkedIn-Kompetenz für KMUs und Agenturen, die wachsen wollen. 25 Jahre Erfahrung in Konzern, Mittelstand und Agenturen.",
// Die Ansprache-Logik für den Critic-Node
"form_of_address":
Duzen (Du/Euch) - direkt, leidenschaftlich und hochemotional,
"style_guide":
Kommunikation als Sales-Infrastruktur. Fokus auf ROI ('Alles was nicht einzahlt, muss weg'). Stil: Laut, ehrlich, begeisterungsfähig ('Halleluja!', 'Galopp!'). Nutzt Storytelling durch Flashbacks und persönliche Anekdoten, um strategische Punkte zu belegen.",
"topic_history": [
"KI-Suche als Sales-Infrastruktur","Kommunikation treibt Sales über KI","Positionierung für den Mittelstand (leise/komplexe Unternehmen)","LinkedIn als Teil der klassischen Unternehmenskommunikation","Die 'Zweite Pubertät' 2026: Neuanfang und Mut"
],
"example_posts": [
`𝗞𝗜-𝗦𝘂𝗰𝗵𝗲 𝗶𝘀𝘁 𝗱𝗲𝗿 𝗲𝗿𝘀𝘁𝗲 𝗦𝗰𝗵𝗿𝗶𝘁𝘁 𝗶𝗺 𝗦𝗮𝗹𝗲𝘀 𝗙𝘂𝗻𝗻𝗲𝗹. Gute Kommunikation ist Sales-Infrastruktur. Das ist unsere Chance Christina!!! ❞
Das sagte Max kurz vor Weihnachten in einem Café zu mir und ich schrie ihn fast an:
"Max!! Sag das nochmal!!! Das ist genial!!! Das ist unser nächster Post!"
Und…
DA IST ER:)!🚀
Warum ich so begeistert war?
Ich glaube, dass Kommunikation unbedingt auch Sales „driven“ muss.
Make It Matter ist entstanden, weil es nur wenige Agenturen gibt, die so radikal+konsequent Kommunikation als Sales- und Leads-Wachstumstreiber mitdenken wie wir.
ALLES was nicht auf die Unternehnensziele einzahlt muss radikal WEG.
Lisa und ich haben EIN Ziel: Die New Business- bzw. Vertriebsmannschaft soll uns lieben:)
KI spielt uns in die Karten und ist wie ein Brennglas:
Die KI entscheidet, wer und überhaupt noch empfohlen wird und wer auf Shortlists landet.
JETZT muss man kommunikative KI-Signale setzen und das „Dach decken“. JETZT kann man KI-Pflöcke im Netz einziehen, damit man im Sales-Entscheidungsprozess VORNE sichtbar wird.
𝗞𝗼𝗺𝗺𝘂𝗻𝗶𝗸𝗮𝘁𝗶𝗼𝗻 𝘁𝗿𝗲𝗶𝗯𝘁 𝗦𝗮𝗹𝗲𝘀 ü𝗯𝗲𝗿 𝗞𝗜. Jetzt und künftig noch mehr!
Im Gespräch mit Max wurde das sehr klar: KI recherchiert nicht mehr wie früher, sie priorisiert und bewertet, lange bevor der Vertrieb überhaupt spricht.
Es gilt:
👉Wenn KI ein Unternehmen nicht eindeutig einordnen kann, ist es für Sales nicht im Rennen.
👉Genau hier wird Kommunikation zur Sales-Infrastruktur.
Make It Matter Matter schafft dafür die strategische Grundlage aus Positionierung, Themenarchitektur und Public Relations als echte Third-Party-Validierung.
(Klingt gut oder?! Und ist wahr!!)
Unsre Stärken?
👉LISA HIPP Hipp übersetzt diese Klarheit auf LinkedIn in wiederholbare Narrative, die Einordnung erzeugen.
👉Max Anzile sorgt dafür, dass diese Signale gezielt distribuiert, getestet und messbar gemacht werden.
👉 Und ich bin Kommunikation und Public Relations durch und durch (Konzern, Mittelstand, Agenturen. 25 Jahre
So entsteht für undere kein Content-Feuerwerk, sondern ein SYSTEM, das SALES-VORAUSWAHL gewinnt.
💸💸💸
Wir sehen in der Praxis:
Deals mit mehreren Kommunikationskontakten
👉schliessen schneller und stabile
👉und brauchen weniger Rabatt
weil Vertrauen steigt und Vergleichbarkeit sinkt.
Unsere Überzeugung ist klar:
Entweder Kommunikation ist messbarer Teil des Sales Funnels oder sie wird 2026 gestrichen.
Sorry to say!!
Good for us!!!!🙃
Christina
#Kommunikation #SalesSupport #SocialMedia #KISEO`,
`Ein unstrategischer Flashback-Post aus dem Flixbus Richtung Bodensee.
#AboutEducatedAwareness #AboutMittelstand #AusDerHüfteGeschossen
Sorry Lisa🙈
Gestern habe ich brav den Keller aufgeräumt und bin an einer Kiste alter Job-Fotos hängengeblieben.
📸
Ich in Amsterdam mit Tommy Hilfiger. Beim Abendessen mit Lovely Annette Weber, Flohmärkte mit Chefredakteuren, Bilder mit Steffi Graf, Sönke Wortmann, Natalia Wörner und Heike Makatsch in Nördlingen bei Strenesse. Cartier-Juste-Un-Clou-Launch in New York.
Halleluja war ich wichtig, dünn, lustig und jung :)
Was für eine goldene, sorglose Zeit*.
💫💫💫
Bei diesem Foto👇 musste ich so lachen. Ich weiß noch, wie es entstanden ist:
Wir waren Sponsor beim Bambi. (Wir damit meine ich Cartier damals.)
Mein Chef Tom Meggle gab mir damals die Erlaubnis, meine Freundinnen Bine Käfer (jetzt Lanz), Celia von Bismarck und Gioia von Thun mitzunehmen.
Wichtig wichtig.
Wir also aufgedresst wie Bolle, kommen an den roten Teppich, Blitzgewitter, 4 Girlies. Dann ein Rufen aus der Fotografenmenge:
„Christina!!! Kannst Du aus dem Bild gehen??? Wir brauchen die 3 Mädels alleine!!!“ (weil echt wichtig).
Ich weiß noch, wie ich fast zusammengebrochen bin vor Lachen. „Hey!! ICH habe DIE mitgenommen!!“ 🤣🤣🤣
Ein Bild von uns 4 hab ich dann aber doch noch bekommen. Plus einen wundervollen Abend.
….
Was das mit Make It Matter äund „Job“ zu tun hat?
Wenig. Aber ein bisschen schon:
Es zeigt aber, wie sehr ich mich verändert habe:
💫𝗛𝗲𝘂𝘁𝗲 𝗮𝗿𝗯𝗲𝗶𝘁𝗲 𝗶𝗰𝗵 𝗮𝗺 𝗹𝗶𝗲𝗯𝘀𝘁𝗲𝗻 𝗳𝘂̈𝗿 𝗨𝗻𝘁𝗲𝗿𝗻𝗲𝗵𝗺𝗲𝗻, 𝗱𝗶𝗲 𝗴𝗿𝗼ß𝗮𝗿𝘁𝗶𝗴 𝘀𝗶𝗻𝗱, 𝗮𝗯𝗲𝗿 𝘇𝘂 𝗹𝗲𝗶𝘀𝗲, 𝘇𝘂 𝗸𝗼𝗺𝗽𝗹𝗲𝘅 𝗼𝗱𝗲𝗿 𝘇𝘂 𝗯𝗲𝘀𝗰𝗵𝗲𝗶𝗱𝗲𝗻, 𝘂𝗺 𝘀𝗲𝗹𝗯𝘀𝘁 𝘂̈𝗯𝗲𝗿 𝗶𝗵𝗿𝗲 𝗦𝘁𝗮̈𝗿𝗸𝗲 𝘇𝘂 𝘀𝗽𝗿𝗲𝗰𝗵𝗲𝗻
(Es gibt so so so tolle Firmen, von denen noch keiner etwas gehört hat, meistens mit unglaublich netten Teams!)
💫 Heute habe ich mich in erklärungsbedürftige, komplizierte Produkte, EducatedAwareness und #LinkedIn als Teil der klassischen #Unternehmenskommunikation verliebt.
💫 Heute liebe ich komplexe und „unsexy“ Aufgaben, die ich knacken will.
So verändert man sich. Ist das nicht verrückt?
Schön wars trotzdem damals. Mensch, bin ich dankbar!
Euch einen schönen Sonntag. Eure Christina
www.make-it-matter.de
#MakeItMatter #Kommunikation #Wachstumstreiber #Mittelstand #PublicRelations
PS. Ich setzt mich jetzt gleich an das Papier liebe Lisa. Bis 11.15h gab ich fertig😉`,
`𝗪𝗘𝗥 𝗠𝗔𝗖𝗛𝗧 𝗠𝗜𝗧??? Ich habe entschieden, dass 2026 meine zweite Pubertät beginnt #MakeItMatter🚀
Halleluja! Endlich hat das neue Jahr begonnen.
Mit meinen Freunden habe ich gestern beschlossen, dass ich das kommende Jahr jetzt mal völlig neu angehen werde:
Ich stelle mir einfach vor, ich wäre in meiner zweiten Pubertät!!!
Ok, ok, dieses Mal mit besserem Wein, einem kleinen Kontopuffer, 25 Jahren Berufserfahrung, besserem WLAN, aber mit dem gleichen Gefühl von damals:
𝗗𝗘𝗥 𝗡𝗔̈𝗖𝗛𝗦𝗧𝗘 𝗟𝗘𝗕𝗘𝗡𝗦𝗔𝗕𝗦𝗖𝗛𝗡𝗜𝗧𝗧 𝗪𝗜𝗥𝗗 𝗗𝗘𝗥 𝗕𝗘𝗦𝗧𝗘!!!
Ich fühle es! Alles liegt vor mir und ich kann Pippi-Langstrumpf-mäßig einfach alles erreichen, à la:
„𝘐𝘤𝘩 𝘩𝘢𝘣 𝘥𝘢𝘴 𝘯𝘰𝘤𝘩 𝘯𝘪𝘦 𝘨𝘦𝘮𝘢𝘤𝘩𝘵 𝘥𝘢𝘴 𝘬𝘢𝘯𝘯 𝘪𝘤𝘩 𝘣𝘦𝘴𝘵𝘪𝗺𝘮𝘵.“
(PS: Wann haben wir das eigentlich verlernt?)
Ich finde die Ähnlichkeit zu meinem (nicht mehr pubertierenden) Sohn Lenny wirklich erstaunlich:
Er ist genauso begeistert von der Idee, im Ausland zu studieren und sich etwas Eigenes, Großes aufzubauen, wie ich besessen von Lisas und meiner Make-It-Matter-Idee bin.
Jetzt sitzen wir auf dem Driverseat unseres Lebens und können Kommunikations-Burgen aufbauen: das, was wir am allerbesten können!
Wir mussten gestern bei dem Erste-und-Zweite-Pubertäts-Vergleich wirklich lachen:
Auch die Hormonprobleme sind ähnlich. Nur habe ich meine im Griff bzw. hinter mir. Lenny hat noch eine (aufregende) Reise vor sich.
Muss man da nicht automatisch grinsen?
Dieses Grinsen werde ich mir für 2026 vornehmen. Ich werde mich öfter an den Sternenzauber meiner ersten Pubertät erinnern, an die unaufhaltsame Kraft, die Fröhlichkeit, den Mut, die Neugierde und die Ausdauer.
Und wisst ihr, worauf ich mich am meisten freue?
Auf die Momente, in denen ich bei den Social Media Pirates - we are hiring we are hiring ins Büro komme und Max Anzile (der übrigens 20 Jahre jünger ist als ich) zu mir sagt:
„Guten Morgen, Boomer!“ …und frech grinst. Auf seine KI-Sessions, seine Ideen, neue Welten, Hummeln im Popo.
Und natürlich LISA HIPP, die allerallerallerbeste Geschäftspartnerin, die ich mir vorstellen kann. #ZamReinZamRaus
Seid ihr dabei? 💫 Zweite Pubertät ab 2026? 💪 Vollgas? ✔️ Lebensfreude? 🫶 Kommunikation neu denken? ✔️ Jahr des Feuerpferdes? 🐎
Dann GALOPP!!!!!
Ich wünsche euch, dass all eure Träume und Wünsche in Erfüllung gehen und dass ihr den Mut habt, etwas dafür zu tun!
„Wenn die Sehnsucht größer ist als die Angst, wird Mut, Erfolg und Lebensfreude geboren.“
Ist das nicht schön?
Happy New Year und happy neue Lebensphase(n)
wünscht Euch Christina
#MakeItMatter #Kommunikation #PublicRelations #LinkedInComms`
]
};

67
docker-compose.ssl.yml Normal file
View File

@@ -0,0 +1,67 @@
version: '3.8'
services:
linkedin-posts:
build: .
container_name: linkedin-posts
restart: unless-stopped
expose:
- "8000"
env_file:
- .env
environment:
- PYTHONPATH=/app
- VIRTUAL_HOST=linkedin.onyva.dev
- VIRTUAL_PORT=8000
- LETSENCRYPT_HOST=linkedin.onyva.dev
volumes:
- ./logs:/app/logs
healthcheck:
test: ["CMD", "python", "-c", "import httpx; httpx.get('http://localhost:8000/login', timeout=5)"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
networks:
- proxy-network
nginx-proxy:
image: nginxproxy/nginx-proxy
container_name: nginx-proxy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
environment:
- DEFAULT_HOST=linkedin.onyva.dev
volumes:
- /var/run/docker.sock:/tmp/docker.sock:ro
- certs:/etc/nginx/certs
- html:/usr/share/nginx/html
- vhost:/etc/nginx/vhost.d
networks:
- proxy-network
acme-companion:
image: nginxproxy/acme-companion
container_name: acme-companion
restart: unless-stopped
volumes_from:
- nginx-proxy
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- acme:/etc/acme.sh
environment:
- DEFAULT_EMAIL=ruben.fischer@onyva.de
networks:
- proxy-network
networks:
proxy-network:
driver: bridge
volumes:
certs:
html:
vhost:
acme:

37
docker-compose.yml Normal file
View File

@@ -0,0 +1,37 @@
version: '3.8'
services:
linkedin-posts:
build: .
container_name: linkedin-posts
restart: unless-stopped
ports:
- "8000:8000"
env_file:
- .env
environment:
- PYTHONPATH=/app
volumes:
# Optional: Mount logs directory
- ./logs:/app/logs
healthcheck:
test: ["CMD", "python", "-c", "import httpx; httpx.get('http://localhost:8000/login', timeout=5)"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
# Optional: Nginx reverse proxy with SSL
# Uncomment if you want to use Nginx
# nginx:
# image: nginx:alpine
# container_name: linkedin-posts-nginx
# restart: unless-stopped
# ports:
# - "80:80"
# - "443:443"
# volumes:
# - ./nginx.conf:/etc/nginx/nginx.conf:ro
# - ./ssl:/etc/nginx/ssl:ro
# depends_on:
# - linkedin-posts

BIN
logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

50
main.py Normal file
View File

@@ -0,0 +1,50 @@
#!/usr/bin/env python3
"""
LinkedIn Post Creation System
Multi-Agent AI Workflow
Main entry point for the application.
"""
import asyncio
from loguru import logger
from src.tui.app import run_app
def setup_logging():
"""Configure logging - only to file, not console."""
# Remove default handler that logs to console
logger.remove()
# Add file handler only
logger.add(
"logs/workflow_{time:YYYY-MM-DD}.log",
rotation="1 day",
retention="7 days",
level="INFO",
format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}"
)
logger.info("Logging configured (file only)")
def main():
"""Main entry point."""
# Setup logging
setup_logging()
logger.info("Starting LinkedIn Workflow System")
# Run TUI application
try:
run_app()
except KeyboardInterrupt:
logger.info("Application interrupted by user")
except Exception as e:
logger.exception(f"Application error: {e}")
raise
finally:
logger.info("Application shutdown")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,146 @@
#!/usr/bin/env python3
"""
Maintenance script to remove repost/non-regular posts from the database.
This removes LinkedIn posts that are reposts, shares, or any other non-original content.
Only posts with post_type "regular" in their raw_data should remain.
Usage:
python maintenance_cleanup_reposts.py # Dry run (preview what will be deleted)
python maintenance_cleanup_reposts.py --apply # Actually delete the posts
"""
import asyncio
import sys
from uuid import UUID
from loguru import logger
from src.database import db
async def cleanup_reposts(apply: bool = False):
"""
Find and remove all non-regular posts from the database.
Args:
apply: If True, delete posts. If False, just preview.
"""
logger.info("Loading all customers...")
customers = await db.list_customers()
total_posts = 0
regular_posts = 0
posts_to_delete = []
for customer in customers:
posts = await db.get_linkedin_posts(customer.id)
for post in posts:
total_posts += 1
# Check post_type in raw_data
post_type = None
if post.raw_data and isinstance(post.raw_data, dict):
post_type = post.raw_data.get("post_type", "").lower()
if post_type == "regular":
regular_posts += 1
else:
posts_to_delete.append({
'id': post.id,
'customer': customer.name,
'post_type': post_type or 'unknown',
'text_preview': (post.post_text[:80] + '...') if post.post_text and len(post.post_text) > 80 else post.post_text,
'url': post.post_url
})
# Print summary
print(f"\n{'='*70}")
print(f"SCAN RESULTS")
print(f"{'='*70}")
print(f"Total posts scanned: {total_posts}")
print(f"Regular posts (keep): {regular_posts}")
print(f"Non-regular (delete): {len(posts_to_delete)}")
if not posts_to_delete:
print("\nNo posts to delete! Database is clean.")
return
# Show posts to delete
print(f"\n{'='*70}")
print(f"POSTS TO DELETE")
print(f"{'='*70}")
# Group by post_type for cleaner output
by_type = {}
for post in posts_to_delete:
pt = post['post_type']
if pt not in by_type:
by_type[pt] = []
by_type[pt].append(post)
for post_type, posts in by_type.items():
print(f"\n[{post_type.upper()}] - {len(posts)} posts")
print("-" * 50)
for post in posts[:5]: # Show max 5 per type
print(f" Customer: {post['customer']}")
print(f" Preview: {post['text_preview']}")
print(f" ID: {post['id']}")
print()
if len(posts) > 5:
print(f" ... and {len(posts) - 5} more {post_type} posts\n")
if apply:
print(f"\n{'='*70}")
print(f"DELETING {len(posts_to_delete)} POSTS...")
print(f"{'='*70}")
deleted = 0
errors = 0
for post_data in posts_to_delete:
try:
await asyncio.to_thread(
lambda pid=post_data['id']:
db.client.table("linkedin_posts").delete().eq("id", str(pid)).execute()
)
deleted += 1
if deleted % 10 == 0:
print(f" Deleted {deleted}/{len(posts_to_delete)}...")
except Exception as e:
logger.error(f"Failed to delete post {post_data['id']}: {e}")
errors += 1
print(f"\nDone! Deleted {deleted} posts. Errors: {errors}")
else:
print(f"\n{'='*70}")
print(f"DRY RUN - No changes made.")
print(f"Run with --apply to delete these {len(posts_to_delete)} posts.")
print(f"{'='*70}")
async def main():
apply = '--apply' in sys.argv
if apply:
print("="*70)
print("MODE: DELETE POSTS")
print("="*70)
print(f"\nThis will permanently delete non-regular posts from the database.")
print("This action cannot be undone!\n")
response = input("Are you sure? Type 'DELETE' to confirm: ")
if response != 'DELETE':
print("Aborted.")
return
else:
print("="*70)
print("MODE: DRY RUN (preview only)")
print("="*70)
print("Add --apply flag to actually delete posts.\n")
await cleanup_reposts(apply=apply)
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,93 @@
#!/usr/bin/env python3
"""
Maintenance script to extract and save topics for existing customers.
This script:
1. Loads all customers
2. For each customer, extracts topics from existing posts
3. Saves extracted topics to the topics table
4. Also saves any topics from research results to the topics table
"""
import asyncio
from loguru import logger
from src.database import db
from src.agents import TopicExtractorAgent
async def extract_and_save_topics_for_customer(customer_id):
"""Extract and save topics for a single customer."""
logger.info(f"Processing customer: {customer_id}")
# Get customer
customer = await db.get_customer(customer_id)
if not customer:
logger.error(f"Customer {customer_id} not found")
return
logger.info(f"Customer: {customer.name}")
# Get LinkedIn posts
posts = await db.get_linkedin_posts(customer_id)
logger.info(f"Found {len(posts)} posts")
if not posts:
logger.warning("No posts found, skipping topic extraction")
else:
# Extract topics from posts
logger.info("Extracting topics from posts...")
topic_extractor = TopicExtractorAgent()
try:
topics = await topic_extractor.process(
posts=posts,
customer_id=customer_id
)
if topics:
# Save topics
saved_topics = await db.save_topics(topics)
logger.info(f"✓ Saved {len(saved_topics)} extracted topics")
else:
logger.warning("No topics extracted")
except Exception as e:
logger.error(f"Failed to extract topics: {e}", exc_info=True)
logger.info(f"Finished processing customer: {customer.name}\n")
async def main():
"""Main function."""
logger.info("=== TOPIC EXTRACTION MAINTENANCE SCRIPT ===\n")
# List all customers
customers = await db.list_customers()
if not customers:
logger.warning("No customers found")
return
logger.info(f"Found {len(customers)} customers\n")
# Process each customer
for customer in customers:
try:
await extract_and_save_topics_for_customer(customer.id)
except Exception as e:
logger.error(f"Error processing customer {customer.id}: {e}", exc_info=True)
logger.info("\n=== MAINTENANCE COMPLETE ===")
if __name__ == "__main__":
# Setup logging
logger.add(
"logs/maintenance_{time:YYYY-MM-DD}.log",
rotation="1 day",
retention="7 days",
level="INFO"
)
# Run
asyncio.run(main())

View File

@@ -0,0 +1,181 @@
#!/usr/bin/env python3
"""
Maintenance script to convert Markdown bold (**text**) to Unicode bold.
This fixes posts that contain Markdown formatting which doesn't render on LinkedIn.
Unicode bold characters are used instead, which display correctly on LinkedIn.
Usage:
python maintenance_fix_markdown_bold.py # Dry run (preview changes)
python maintenance_fix_markdown_bold.py --apply # Apply changes to database
"""
import asyncio
import re
import sys
from uuid import UUID
from loguru import logger
from src.database import db
# Unicode Bold character mappings (Mathematical Sans-Serif Bold)
BOLD_MAP = {
# Uppercase A-Z
'A': '𝗔', 'B': '𝗕', 'C': '𝗖', 'D': '𝗗', 'E': '𝗘', 'F': '𝗙', 'G': '𝗚',
'H': '𝗛', 'I': '𝗜', 'J': '𝗝', 'K': '𝗞', 'L': '𝗟', 'M': '𝗠', 'N': '𝗡',
'O': '𝗢', 'P': '𝗣', 'Q': '𝗤', 'R': '𝗥', 'S': '𝗦', 'T': '𝗧', 'U': '𝗨',
'V': '𝗩', 'W': '𝗪', 'X': '𝗫', 'Y': '𝗬', 'Z': '𝗭',
# Lowercase a-z
'a': '𝗮', 'b': '𝗯', 'c': '𝗰', 'd': '𝗱', 'e': '𝗲', 'f': '𝗳', 'g': '𝗴',
'h': '𝗵', 'i': '𝗶', 'j': '𝗷', 'k': '𝗸', 'l': '𝗹', 'm': '𝗺', 'n': '𝗻',
'o': '𝗼', 'p': '𝗽', 'q': '𝗾', 'r': '𝗿', 's': '𝘀', 't': '𝘁', 'u': '𝘂',
'v': '𝘃', 'w': '𝘄', 'x': '𝘅', 'y': '𝘆', 'z': '𝘇',
# Numbers 0-9
'0': '𝟬', '1': '𝟭', '2': '𝟮', '3': '𝟯', '4': '𝟰',
'5': '𝟱', '6': '𝟲', '7': '𝟳', '8': '𝟴', '9': '𝟵',
# German umlauts
'Ä': '𝗔̈', 'Ö': '𝗢̈', 'Ü': '𝗨̈',
'ä': '𝗮̈', 'ö': '𝗼̈', 'ü': '𝘂̈',
'ß': 'ß', # No bold variant, keep as is
}
def to_unicode_bold(text: str) -> str:
"""Convert plain text to Unicode bold characters."""
result = []
for char in text:
result.append(BOLD_MAP.get(char, char))
return ''.join(result)
def convert_markdown_bold(content: str) -> str:
"""
Convert Markdown bold (**text**) to Unicode bold.
Also handles:
- __text__ (alternative markdown bold)
- Nested or multiple occurrences
"""
# Pattern for **text** (non-greedy, handles multiple)
pattern_asterisk = r'\*\*(.+?)\*\*'
# Pattern for __text__
pattern_underscore = r'__(.+?)__'
def replace_with_bold(match):
inner_text = match.group(1)
return to_unicode_bold(inner_text)
# Apply conversions
result = re.sub(pattern_asterisk, replace_with_bold, content)
result = re.sub(pattern_underscore, replace_with_bold, result)
return result
def has_markdown_bold(content: str) -> bool:
"""Check if content contains Markdown bold syntax."""
return bool(re.search(r'\*\*.+?\*\*|__.+?__', content))
async def fix_all_posts(apply: bool = False):
"""
Find and fix all posts with Markdown bold formatting.
Args:
apply: If True, apply changes to database. If False, just preview.
"""
logger.info("Loading all customers...")
customers = await db.list_customers()
total_posts = 0
posts_with_markdown = 0
fixed_posts = []
for customer in customers:
posts = await db.get_generated_posts(customer.id)
for post in posts:
total_posts += 1
if not post.post_content:
continue
if has_markdown_bold(post.post_content):
posts_with_markdown += 1
original = post.post_content
converted = convert_markdown_bold(original)
fixed_posts.append({
'id': post.id,
'customer': customer.name,
'topic': post.topic_title,
'original': original,
'converted': converted,
})
# Show preview
print(f"\n{'='*60}")
print(f"Post: {post.topic_title}")
print(f"Customer: {customer.name}")
print(f"ID: {post.id}")
print(f"{'-'*60}")
# Find and highlight the changes
bold_matches = re.findall(r'\*\*(.+?)\*\*|__(.+?)__', original)
for match in bold_matches:
text = match[0] or match[1]
print(f" **{text}** → {to_unicode_bold(text)}")
print(f"\n{'='*60}")
print(f"SUMMARY")
print(f"{'='*60}")
print(f"Total posts scanned: {total_posts}")
print(f"Posts with Markdown bold: {posts_with_markdown}")
if not fixed_posts:
print("\nNo posts need fixing!")
return
if apply:
print(f"\nApplying changes to {len(fixed_posts)} posts...")
for post_data in fixed_posts:
try:
# Update the post in database
await asyncio.to_thread(
lambda pid=post_data['id'], content=post_data['converted']:
db.client.table("generated_posts").update({
"post_content": content
}).eq("id", str(pid)).execute()
)
logger.info(f"Fixed post: {post_data['topic']}")
except Exception as e:
logger.error(f"Failed to update post {post_data['id']}: {e}")
print(f"\nDone! Fixed {len(fixed_posts)} posts.")
else:
print(f"\nDRY RUN - No changes applied.")
print(f"Run with --apply to fix these {len(fixed_posts)} posts.")
async def main():
apply = '--apply' in sys.argv
if apply:
print("MODE: APPLY CHANGES")
print("This will modify posts in the database.")
response = input("Are you sure? (yes/no): ")
if response.lower() != 'yes':
print("Aborted.")
return
else:
print("MODE: DRY RUN (preview only)")
print("Add --apply flag to actually modify posts.\n")
await fix_all_posts(apply=apply)
if __name__ == "__main__":
asyncio.run(main())

466
post_output.json Normal file
View File

@@ -0,0 +1,466 @@
[
{
"urn": {
"activity_urn": "7419268832684441600",
"share_urn": "7419041318460571648",
"ugcPost_urn": null
},
"full_urn": "urn:li:activity:7419268832684441600",
"posted_at": {
"date": "2026-01-20 07:45:33",
"relative": "11 hours ago • Edited • Visible to anyone on or off LinkedIn",
"timestamp": 1768891533061
},
"text": "𝗞𝘂𝗻𝗱𝗲𝗻 𝘄𝗼𝗹𝗹𝗲𝗻 𝗶𝗺𝗺𝗲𝗿 ö𝗳𝘁𝗲𝗿 𝗧𝗲𝘅𝘁𝗲 𝘃𝗼𝗻 𝘂𝗻𝘀 „𝗲𝗻𝘁-𝗞𝗜-𝗶𝗳𝗶𝘇𝗶𝗲𝗿𝘁“. Hirnschmalz matters! 🤷‍♀️\n\n\nEin guter Trend finde ich.\n\n… und wir bekommen auch immer mehr Aufträge Kommunikationskonzepte von ihrer „undurchdachten Glattheit“ zu befreien.\n\nUnsere Kunden nennen das…\n\n… „re-humanisieren“:)\n\n\n\n\n\nVielleicht könnte das ein neuer Beruf werden?\n\n\n𝗞𝗜-𝗥𝗘-𝗖𝗥𝗘𝗔𝗧𝗢𝗥 … oder\n\n𝗔𝗜 𝗦𝗘𝗡𝗦𝗘𝗠𝗔𝗞𝗜𝗡𝗚 𝗗𝗜𝗥𝗘𝗖𝗧𝗢𝗥… oder\n\n𝗛𝗨𝗠𝗔𝗡 𝗥𝗘𝗪𝗥𝗜𝗧𝗘 𝗦𝗣𝗘𝗖𝗜𝗔𝗟𝗜𝗦𝗧… oder\n\n𝗔𝗜 𝗦𝗧𝗬𝗟𝗘 𝗥𝗘𝗖𝗢𝗡𝗦𝗧𝗥𝗨𝗖𝗧𝗢𝗥…\n\n\nAI Sensemaking Director mag ich besonders. Habt ihr noch andere Titel-Ideen?\n\n….\n\nKurzer privater Ausflug zu diesem Thema:\n\nVor kurzem war ich auf einer Geburtstagsparty. \n\nPaul, der Ehemann, meinte es besonders gut und hat seiner Frau Brigitte eine Liebeserklärungs-Rede gehalten.\n\nEindeutig KI. \nPuuuuuuh.\n\nAlle applaudierten und flüsterten (eigentlich lästerten) hinter seinem Rücken.\n\nEs war bisschen peinlich für ihn.\n\n\nMeine Meinung:\n\nGenau dasselbe passiert gerade in Unternehmen!\n\nEs wird mit Hilfe von KI Einheitsbrei produziert: \n\n- pfurzlangweilige KI-Texte (sorry)\n- lieblose Reden und LinkedIn Posts\n- nicht zu Ende gedache, individuelle Kommunikations-Konzepte\netc\n\n\n\nSo geht gute Kommunikation … \nN I C H T ❌\n\n\nUnsere feste Überzeugung:\n\nNatürlich muss man KI nutzen.\nSie spart Kosten. Aber: \n\n\nBei Make It Matter haben wir zwei Regeln:\n\n\nKI- Regel Nr. 1⃣ \n\n𝗗𝗶𝗲 𝗠𝗲𝗻𝘀𝗰𝗵𝗞𝗜𝗠𝗲𝗻𝘀𝗰𝗵-𝗟𝗼𝗴𝗶𝗸 𝗯𝗹𝗲𝗶𝗯𝘁 𝘂𝗻𝗮𝗻𝘁𝗮𝘀𝘁𝗯𝗮𝗿.\n\nWir nutzen KI nie ohne „menschliche Rücklogik und Hirnschmalz“. \n\nSonst passiert das Gleiche Pauls Rede: Zwar technisch sauber, aber emotional tot, durchschaubar und banal. Vor allem aber erkennt die KI die KI:)\n\n\nKI Regel Nr. 2⃣ \n\n𝗗𝗶𝗲 „𝟲𝟬 % 𝗠𝗲𝗻𝘀𝗰𝗵 𝘃𝘀. 𝟰𝟬 % 𝗞𝗜“-𝗟𝗼𝗴𝗶𝗸.\n\nWir glauben: Mindestens 60 % Menschliche Intelligenz plus Erfahrung. Maximal 40 % KI.\n\n\nAlles andere erzeugt denselben grauen Brei wie im Bild. \n\nUnd wir lieben es doch möglichst bunt, individuell, tailormade und ECHT, oder?\n\nIch persönlich glaube ja, dass Menschen kaufen von Menschen, die sie \"echt spüren\".\n\nDabei? \nOder nicht dabei?\n\nLiebe Grüße\nEure Christina\n\n#Kommunikation\n#PublicRelations\n#LinkedInPR\n#MakeItMatter",
"url": "https://www.linkedin.com/posts/christinahildebrandt_kommunikation-publicrelations-linkedinpr-activity-7419268832684441600-IhhQ?utm_source=social_share_send&utm_medium=member_desktop_web&rcm=ACoAAGMGPDkBwcLGZeQ7c5M2vUoBf0G76hhvo1g",
"post_type": "regular",
"author": {
"first_name": "Christina",
"last_name": "Hildebrandt",
"headline": "Co-Founder MAKE IT MATTER I Kommunikationsberatung, Interimsmanagement, PR & LinkedIn-Kompetenz für KMUs und Agenturen, die wachsen wollen",
"username": "christinahildebrandt",
"profile_url": "https://www.linkedin.com/in/christinahildebrandt?miniProfileUrn=urn%3Ali%3Afsd_profile%3AACoAABZ-U_wBolcXsKNwUaGEDfBwACvP5M5DkmQ",
"profile_picture": "https://media.licdn.com/dms/image/v2/D4D03AQFz-EjLfIatDw/profile-displayphoto-shrink_800_800/B4DZaIPVU5GwAg-/0/1746042443543?e=1770249600&v=beta&t=h61WiK5XGoOjPqfncwXZ6gtB_WvNT7HPs23MX79n3BE"
},
"stats": {
"total_reactions": 156,
"like": 117,
"support": 11,
"love": 3,
"insight": 9,
"celebrate": 16,
"funny": 0,
"comments": 141,
"reposts": 3
},
"media": {
"type": "image",
"url": "https://media.licdn.com/dms/image/v2/D4D22AQGYIluS1b4Txg/feedshare-shrink_1280/B4DZvW6xAqJwAg-/0/1768837288206?e=1770249600&v=beta&t=ODTv1N94nno2U_3CMJsOkdcYcoPCHIWTO6eGRqiONYs",
"images": [
{
"url": "https://media.licdn.com/dms/image/v2/D4D22AQGYIluS1b4Txg/feedshare-shrink_1280/B4DZvW6xAqJwAg-/0/1768837288206?e=1770249600&v=beta&t=ODTv1N94nno2U_3CMJsOkdcYcoPCHIWTO6eGRqiONYs",
"width": 1080,
"height": 1080
}
]
},
"pagination_token": "dXJuOmxpOmFjdGl2aXR5OjczMjQzMTgyODc0MjM1NTM1MzYtMTc0NjI1MzU1ODk1NA=="
},
{
"urn": {
"activity_urn": "7416373971899760640",
"share_urn": "7415336307318644736",
"ugcPost_urn": null
},
"full_urn": "urn:li:activity:7416365884061077504",
"posted_at": {
"date": "2026-01-12 08:02:24",
"relative": "1 week ago • Edited • Visible to anyone on or off LinkedIn",
"timestamp": 1768201344466
},
"text": "Hamburg wir kommen!! \n\n \nChristina und ich haben uns diese Woche Zeit für unsere Jahresplanung genommen. \n\n2026 wird voll: Viele Termine, viele Events, viele Gespräche, auf die wir richtig Lust haben.\n\nAm meisten freuen wir uns aber auf eine Premiere für uns beide: unsere erste OMR 🤩 \n\nWarum?\nWeil wir neugierig sind. Natürlich auf den Hype und das Line Up (sagt man das bei Kongressen?) auf neue Perspektiven, gute Impulse und vor allem auf das Hamburger Netzwerk und darauf, Moritz Schubert endlich wieder live zu sehen!!! \n\n...Endlich mal raus aus der eigenen Bubble!\n\nKleiner Hinweis für alle, die noch überlegen: Aktuell gibt es noch den reduzierten \"Save Bird Preis\". Wer also ohnehin mit dem Gedanken spielt, jetzt ist ein guter Moment (Link im Kommentar).\n\nWir freuen uns auf neue Begegnungen und auf viele bekannte Gesichter.\nHamburg, wir kommen!!\n\nMake It Matter!!\n\n\nPS: Die Hotels scheinen jetzt schon recht voll - wer einen guten Tipp für uns hat - wir freuen uns über eure Empfehlungen (und natürlich auch über Treffen vor Ort!)",
"url": "https://www.linkedin.com/posts/lisa-hipp_hamburg-wir-kommen-christina-und-ich-activity-7416365884061077504-27D4?utm_source=social_share_send&utm_medium=member_desktop_web&rcm=ACoAAGMGPDkBwcLGZeQ7c5M2vUoBf0G76hhvo1g",
"post_type": "repost",
"author": {
"first_name": "LISA",
"last_name": "HIPP",
"headline": "Co-Founder of MAKE IT MATTER I Strategische Kommunikationsberatung, PR & LinkedIn-Kompetenz für KMUs und Agenturen, die wachsen wollen I Interimsmanager I Speakerin I Autorin",
"username": "lisa-hipp",
"profile_url": "https://www.linkedin.com/in/lisa-hipp?miniProfileUrn=urn%3Ali%3Afsd_profile%3AACoAABoIg7cBNIMrNROuYH0Eu8SrT_RBD19epvU",
"profile_picture": "https://media.licdn.com/dms/image/v2/D4E03AQF_O5ImniRHpA/profile-displayphoto-shrink_800_800/B4EZWVGXr8GgAg-/0/1741963230810?e=1770249600&v=beta&t=PGFdRS4hMFX9posogdbIRWixVm-4odWkunMbSm3nbgc"
},
"stats": {
"total_reactions": 161,
"like": 131,
"support": 4,
"love": 13,
"insight": 0,
"celebrate": 13,
"funny": 0,
"comments": 38,
"reposts": 4
},
"media": {
"type": "image",
"url": "https://media.licdn.com/dms/image/v2/D4D22AQEe1KShPsWiQQ/feedshare-shrink_1280/B4DZuiREcqLoAs-/0/1767953945477?e=1770249600&v=beta&t=QNZGVw3XiJny2oEy3rnfZ--UxX74NLsNLmU8cJy6RRg",
"images": [
{
"url": "https://media.licdn.com/dms/image/v2/D4D22AQEe1KShPsWiQQ/feedshare-shrink_1280/B4DZuiREcqLoAs-/0/1767953945477?e=1770249600&v=beta&t=QNZGVw3XiJny2oEy3rnfZ--UxX74NLsNLmU8cJy6RRg",
"width": 1280,
"height": 1600
}
]
},
"pagination_token": "dXJuOmxpOmFjdGl2aXR5OjczMjQzMTgyODc0MjM1NTM1MzYtMTc0NjI1MzU1ODk1NA=="
},
{
"urn": {
"activity_urn": "7415265872736415745",
"share_urn": "7414935899727433728",
"ugcPost_urn": null
},
"full_urn": "urn:li:activity:7414935901157621761",
"posted_at": {
"date": "2026-01-09 06:39:13",
"relative": "1 week ago • Edited • Visible to anyone on or off LinkedIn",
"timestamp": 1767937153038
},
"text": "Engagement ist kein Nice-to-have. Es ist der ehrlichste Indikator für funktionierende Markenkommunikation.\n\nIm Interview spricht Max Anzile, CEO der Social Media Pirates - we are hiring und Gründer der Black Flag Agency - Performance Marketing darüber, warum organisches Engagement heute wichtiger ist als reine Reichweite, wie Algorithmen Vereinfachung und Polarisierung verstärken,\nund weshalb KI Effizienz skaliert, aber keine Qualität ersetzt.\n\nEin Gespräch über Social Media jenseits von Buzzwords zwischen Performance-Realität, Plattformverantwortung und der Frage, wie Marken unter Algorithmus-Druck relevant bleiben.\n\nhttps://lnkd.in/dVCd9chd",
"url": "https://www.linkedin.com/posts/business-punk_engagement-ist-kein-nice-to-have-es-ist-activity-7414935901157621761-77XI?utm_source=social_share_send&utm_medium=member_desktop_web&rcm=ACoAAGMGPDkBwcLGZeQ7c5M2vUoBf0G76hhvo1g",
"post_type": "repost",
"author": {
"first_name": "Business",
"last_name": "Punk",
"headline": "192,310 followers",
"profile_url": "https://www.linkedin.com/company/business-punk/posts"
},
"stats": {
"total_reactions": 68,
"like": 59,
"support": 3,
"love": 4,
"insight": 1,
"celebrate": 1,
"funny": 0,
"comments": 12,
"reposts": 2
},
"media": {
"type": "image",
"url": "https://media.licdn.com/dms/image/v2/D4D22AQFYagCD-dpw9g/feedshare-shrink_2048_1536/B4DZuck6E_LkA0-/0/1767858480734?e=1770249600&v=beta&t=i4yYETzULNCwsL5Knep7mRbmsjxce_yjnWPwIN0CHiQ",
"images": [
{
"url": "https://media.licdn.com/dms/image/v2/D4D22AQFYagCD-dpw9g/feedshare-shrink_2048_1536/B4DZuck6E_LkA0-/0/1767858480734?e=1770249600&v=beta&t=i4yYETzULNCwsL5Knep7mRbmsjxce_yjnWPwIN0CHiQ",
"width": 1500,
"height": 855
}
]
},
"pagination_token": "dXJuOmxpOmFjdGl2aXR5OjczMjQzMTgyODc0MjM1NTM1MzYtMTc0NjI1MzU1ODk1NA=="
},
{
"urn": {
"activity_urn": "7414927614370385920",
"share_urn": "7409496092234461184",
"ugcPost_urn": null
},
"full_urn": "urn:li:activity:7414927614370385920",
"posted_at": {
"date": "2026-01-08 08:15:05",
"relative": "1 week ago • Edited • Visible to anyone on or off LinkedIn",
"timestamp": 1767856505959
},
"text": "❝ 𝗞𝗜-𝗦𝘂𝗰𝗵𝗲 𝗶𝘀𝘁 𝗱𝗲𝗿 𝗲𝗿𝘀𝘁𝗲 𝗦𝗰𝗵𝗿𝗶𝘁𝘁 𝗶𝗺 𝗦𝗮𝗹𝗲𝘀 𝗙𝘂𝗻𝗻𝗲𝗹. Gute Kommunikation ist Sales-Infrastruktur. Das ist unsere Chance Christina!!! ❞\n\n\nDas sagte Max kurz vor Weihnachten in einem Café zu mir und ich schrie ihn fast an: \n\n\"Max!! Sag das nochmal!!! Das ist genial!!! Das ist unser nächster Post!\"\n\nUnd…\n\nDA IST ER:)!🚀\n\n\nWarum ich so begeistert war?\n\nIch glaube, dass Kommunikation unbedingt auch Sales „driven“ muss. \n\nMake It Matter ist entstanden, weil es nur wenige Agenturen gibt, die so radikal+konsequent Kommunikation als Sales- und Leads-Wachstumstreiber mitdenken wie wir.\n\nALLES was nicht auf die Unternehnensziele einzahlt muss radikal WEG.\n\nLisa und ich haben EIN Ziel: Die New Business- bzw. Vertriebsmannschaft soll uns lieben:)\n\n\nKI spielt uns in die Karten und ist wie ein Brennglas:\n\nDie KI entscheidet, wer und überhaupt noch empfohlen wird und wer auf Shortlists landet.\n\n\nJETZT muss man kommunikative KI-Signale setzen und das „Dach decken“. JETZT kann man KI-Pflöcke im Netz einziehen, damit man im Sales-Entscheidungsprozess VORNE sichtbar wird.\n\n𝗞𝗼𝗺𝗺𝘂𝗻𝗶𝗸𝗮𝘁𝗶𝗼𝗻 𝘁𝗿𝗲𝗶𝗯𝘁 𝗦𝗮𝗹𝗲𝘀 ü𝗯𝗲𝗿 𝗞𝗜.\nJetzt und künftig noch mehr!\n\n\n\nIm Gespräch mit Max wurde das sehr klar: KI recherchiert nicht mehr wie früher, sie priorisiert und bewertet, lange bevor der Vertrieb überhaupt spricht. \n\nEs gilt:\n\n👉Wenn KI ein Unternehmen nicht eindeutig einordnen kann, ist es für Sales nicht im Rennen.\n\n👉Genau hier wird Kommunikation zur Sales-Infrastruktur.\n\n\nMake It Matter Matter schafft dafür die strategische Grundlage aus Positionierung, Themenarchitektur und Public Relations als echte Third-Party-Validierung. \n\n(Klingt gut oder?! Und ist wahr!!)\n\nUnsre Stärken?\n\n👉LISA HIPP Hipp übersetzt diese Klarheit auf LinkedIn in wiederholbare Narrative, die Einordnung erzeugen. \n\n👉Max Anzile sorgt dafür, dass diese Signale gezielt distribuiert, getestet und messbar gemacht werden.\n\n👉 Und ich bin Kommunikation und Public Relations durch und durch (Konzern, Mittelstand, Agenturen. 25 Jahre\n\n\n\nSo entsteht für undere kein Content-Feuerwerk, sondern ein SYSTEM, das SALES-VORAUSWAHL gewinnt.\n\n💸💸💸\n\n\nWir sehen in der Praxis:\n\nDeals mit mehreren Kommunikationskontakten \n\n👉schliessen schneller und stabile\n👉und brauchen weniger Rabatt \n\nweil Vertrauen steigt und Vergleichbarkeit sinkt.\n\nUnsere Überzeugung ist klar: \n\nEntweder Kommunikation ist messbarer Teil des Sales Funnels oder sie wird 2026 gestrichen.\n\nSorry to say!!\nGood for us!!!!🙃\n\nChristina\n\n#Kommunikation\n#SalesSupport\n#SocialMedia\n#KISEO",
"url": "https://www.linkedin.com/posts/christinahildebrandt_kommunikation-salessupport-socialmedia-activity-7414927614370385920-nQPN?utm_source=social_share_send&utm_medium=member_desktop_web&rcm=ACoAAGMGPDkBwcLGZeQ7c5M2vUoBf0G76hhvo1g",
"post_type": "regular",
"author": {
"first_name": "Christina",
"last_name": "Hildebrandt",
"headline": "Co-Founder MAKE IT MATTER I Kommunikationsberatung, Interimsmanagement, PR & LinkedIn-Kompetenz für KMUs und Agenturen, die wachsen wollen",
"username": "christinahildebrandt",
"profile_url": "https://www.linkedin.com/in/christinahildebrandt?miniProfileUrn=urn%3Ali%3Afsd_profile%3AACoAABZ-U_wBolcXsKNwUaGEDfBwACvP5M5DkmQ",
"profile_picture": "https://media.licdn.com/dms/image/v2/D4D03AQFz-EjLfIatDw/profile-displayphoto-shrink_800_800/B4DZaIPVU5GwAg-/0/1746042443543?e=1770249600&v=beta&t=h61WiK5XGoOjPqfncwXZ6gtB_WvNT7HPs23MX79n3BE"
},
"stats": {
"total_reactions": 74,
"like": 59,
"support": 0,
"love": 6,
"insight": 6,
"celebrate": 3,
"funny": 0,
"comments": 32,
"reposts": 2
},
"media": {
"type": "image",
"url": "https://media.licdn.com/dms/image/v2/D5622AQEoxrN--83wDQ/feedshare-shrink_1280/B56ZtPRbJuJcAs-/0/1766561528601?e=1770249600&v=beta&t=drgs7rI2GEOGWUoeRwZsJNWQwUsPgs2ScPY0IH4OT1o",
"images": [
{
"url": "https://media.licdn.com/dms/image/v2/D5622AQEoxrN--83wDQ/feedshare-shrink_1280/B56ZtPRbJuJcAs-/0/1766561528601?e=1770249600&v=beta&t=drgs7rI2GEOGWUoeRwZsJNWQwUsPgs2ScPY0IH4OT1o",
"width": 1252,
"height": 1680
}
]
},
"pagination_token": "dXJuOmxpOmFjdGl2aXR5OjczMjQzMTgyODc0MjM1NTM1MzYtMTc0NjI1MzU1ODk1NA=="
},
{
"urn": {
"activity_urn": "7413495163643121665",
"share_urn": "7413495162020028416",
"ugcPost_urn": null
},
"full_urn": "urn:li:activity:7413495163643121665",
"posted_at": {
"date": "2026-01-04 09:23:03",
"relative": "2 weeks ago • Edited • Visible to anyone on or off LinkedIn",
"timestamp": 1767514983092
},
"text": "Ein unstrategischer Flashback-Post aus dem Flixbus Richtung Bodensee.\n#AboutEducatedAwareness\n#AboutMittelstand\n\n#AusDerHüfteGeschossen\nSorry Lisa🙈\n\n⸻\n\nGestern habe ich brav den Keller aufgeräumt und bin an einer Kiste alter Job-Fotos hängengeblieben.\n📸\n\nIch in Amsterdam mit Tommy Hilfiger. Beim Abendessen mit Lovely Annette Weber, Flohmärkte mit Chefredakteuren, Bilder mit Steffi Graf, Sönke Wortmann, Natalia Wörner und Heike Makatsch in Nördlingen bei Strenesse. Cartier-Juste-Un-Clou-Launch in New York.\n\nHalleluja war ich wichtig, dünn, lustig und jung :)\n\nWas für eine goldene, sorglose Zeit*.\n💫💫💫\n\nBei diesem Foto👇 musste ich so lachen. Ich weiß noch, wie es entstanden ist:\n\nWir waren Sponsor beim Bambi. (Wir damit meine ich Cartier damals.)\n\nMein Chef Tom Meggle gab mir damals die Erlaubnis, meine Freundinnen Bine Käfer (jetzt Lanz), Celia von Bismarck und Gioia von Thun mitzunehmen.\n\nWichtig wichtig.\n\nWir also aufgedresst wie Bolle, kommen an den roten Teppich, Blitzgewitter, 4 Girlies. Dann ein Rufen aus der Fotografenmenge:\n\n„Christina!!! Kannst Du aus dem Bild gehen??? Wir brauchen die 3 Mädels alleine!!!“ (weil echt wichtig).\n\nIch weiß noch, wie ich fast zusammengebrochen bin vor Lachen. „Hey!! ICH habe DIE mitgenommen!!“ 🤣🤣🤣\n\nEin Bild von uns 4 hab ich dann aber doch noch bekommen. Plus einen wundervollen Abend.\n\n….\n\nWas das mit Make It Matter äund „Job“ zu tun hat?\n\nWenig. Aber ein bisschen schon:\n\n\nEs zeigt aber, wie sehr ich mich verändert habe:\n\n💫𝗛𝗲𝘂𝘁𝗲 𝗮𝗿𝗯𝗲𝗶𝘁𝗲 𝗶𝗰𝗵 𝗮𝗺 𝗹𝗶𝗲𝗯𝘀𝘁𝗲𝗻 𝗳𝘂̈𝗿 𝗨𝗻𝘁𝗲𝗿𝗻𝗲𝗵𝗺𝗲𝗻, 𝗱𝗶𝗲 𝗴𝗿𝗼ß𝗮𝗿𝘁𝗶𝗴 𝘀𝗶𝗻𝗱, 𝗮𝗯𝗲𝗿 𝘇𝘂 𝗹𝗲𝗶𝘀𝗲, 𝘇𝘂 𝗸𝗼𝗺𝗽𝗹𝗲𝘅 𝗼𝗱𝗲𝗿 𝘇𝘂 𝗯𝗲𝘀𝗰𝗵𝗲𝗶𝗱𝗲𝗻, 𝘂𝗺 𝘀𝗲𝗹𝗯𝘀𝘁 𝘂̈𝗯𝗲𝗿 𝗶𝗵𝗿𝗲 𝗦𝘁𝗮̈𝗿𝗸𝗲 𝘇𝘂 𝘀𝗽𝗿𝗲𝗰𝗵𝗲𝗻\n\n(Es gibt so so so tolle Firmen, von denen noch keiner etwas gehört hat, meistens mit unglaublich netten Teams!)\n\n\n💫 Heute habe ich mich in erklärungsbedürftige, komplizierte Produkte, EducatedAwareness und #LinkedIn als Teil der klassischen #Unternehmenskommunikation verliebt.\n\n💫 Heute liebe ich komplexe und „unsexy“ Aufgaben, die ich knacken will.\n\nSo verändert man sich.\nIst das nicht verrückt?\n\nSchön wars trotzdem damals.\nMensch, bin ich dankbar!\n\nEuch einen schönen Sonntag\nEure Christina\n\nwww.make-it-matter.de\n\n#MakeItMatter\n#Kommunikation\n#Wachstumstreiber\n#Mittelstand\n#PublicRelations\n\nPS. Ich setzt mich jetzt gleich an das Papier liebe Lisa. Bis 11.15h gab ich fertig😉",
"url": "https://www.linkedin.com/posts/christinahildebrandt_abouteducatedawareness-aboutmittelstand-ausderhaesftegeschossen-activity-7413495163643121665-ghTT?utm_source=social_share_send&utm_medium=member_desktop_web&rcm=ACoAAGMGPDkBwcLGZeQ7c5M2vUoBf0G76hhvo1g",
"post_type": "regular",
"author": {
"first_name": "Christina",
"last_name": "Hildebrandt",
"headline": "Co-Founder MAKE IT MATTER I Kommunikationsberatung, Interimsmanagement, PR & LinkedIn-Kompetenz für KMUs und Agenturen, die wachsen wollen",
"username": "christinahildebrandt",
"profile_url": "https://www.linkedin.com/in/christinahildebrandt?miniProfileUrn=urn%3Ali%3Afsd_profile%3AACoAABZ-U_wBolcXsKNwUaGEDfBwACvP5M5DkmQ",
"profile_picture": "https://media.licdn.com/dms/image/v2/D4D03AQFz-EjLfIatDw/profile-displayphoto-shrink_800_800/B4DZaIPVU5GwAg-/0/1746042443543?e=1770249600&v=beta&t=h61WiK5XGoOjPqfncwXZ6gtB_WvNT7HPs23MX79n3BE"
},
"stats": {
"total_reactions": 256,
"like": 203,
"support": 2,
"love": 33,
"insight": 3,
"celebrate": 6,
"funny": 9,
"comments": 35,
"reposts": 1
},
"media": {
"type": "image",
"url": "https://media.licdn.com/dms/image/v2/D4D22AQFSwyP3pyzGvA/feedshare-shrink_1280/B4DZuIGiQEI8As-/0/1767514980532?e=1770249600&v=beta&t=PcOxfCx7DnRcny6dAO2ZIzX8CYIL5hrmr6-CxUq0hFE",
"images": [
{
"url": "https://media.licdn.com/dms/image/v2/D4D22AQFSwyP3pyzGvA/feedshare-shrink_1280/B4DZuIGiQEI8As-/0/1767514980532?e=1770249600&v=beta&t=PcOxfCx7DnRcny6dAO2ZIzX8CYIL5hrmr6-CxUq0hFE",
"width": 1280,
"height": 1707
}
]
},
"pagination_token": "dXJuOmxpOmFjdGl2aXR5OjczMjQzMTgyODc0MjM1NTM1MzYtMTc0NjI1MzU1ODk1NA=="
},
{
"urn": {
"activity_urn": "7413276570640875520",
"share_urn": "7404415477738983424",
"ugcPost_urn": null
},
"full_urn": "urn:li:activity:7404415479379062784",
"posted_at": {
"date": "2026-01-03 18:54:26",
"relative": "1 month ago • Edited • Visible to anyone on or off LinkedIn",
"timestamp": 1767462866459
},
"text": "W&V ernennt LinkedIn als Schlüsselplattform für CMOs \n\n\nDas Thema LinkedIn wird in vielen Unternehmen nach wie vor nur mit der Kneifzange angefasst.\n\nZu komplex, zu unklar, zu viel Aufwand....\n\nDabei liegt genau hier ein entscheidender Hebel für Sichtbarkeit, Vertrauen und letztlich Geschäftserfolg. Rolf Schröter, Chefredakteur der W&V, Werben & Verkaufen hat sich dem Thema angenommen und in einem aktuellen Beitrag herausgearbeitet, wie CMOs LinkedIn strategisch nutzen können (Link im Kommentar).\n\nDie Quintessenz des Artikels: \n➔ LinkedIn ist kein Add-on im Marketingmix, sondern strategisches Kernelement\n\nDass Christina und ich und unsere Firma Make It Matter bereits zum zweiten Mal unsere Sicht zu diesem Thema ergänzen dürfen, freut uns unglaublich! Nicht nur aus kommunikativer Sicht, sondern vor allem auch, weil es zeigt wie relevant dieses Thema aktuell ist und wie viele Firmen es umtreibt. \n\n\nViele Unternehmen stehen aktuell vor denselben Herausforderungen:\n- Wie schaffe ich es von meinen Kund:innen gehört zu werden?\n- Wie kann ich Sichtbarkeit gezielt auf Geschäftsziele einzahlen lassen, ob Recruiting, Markenaufbau oder Vertrieb?\n- Wie gelingt es, dass Kommunikation nicht im Tagesgeschäft versandet, sondern systematisch aufgebaut wird?\n\n\nGenau darauf haben wir uns spezialisiert:\nWir entwickeln Kommunikationsstrategien, die LinkedIn nicht als Endpunkt, sondern als Werkzeug verstehen! Eingebettet in ein funktionierendes System aus Positionierung, PR und vor allem funktionierenden Systemen.\n\nDas Ergebnis:\n➔ Klarere Positionierung im Markt\n➔ Sichtbarkeit bei den relevanten Zielgruppen\n➔ Entlastung für interne Teams durch systematische Prozesse\n➔ Mehr Vertrauen, bessere Anfragen, kürzere Entscheidungswege\n\n\nDass die W&V dieses Thema erneut aufgreift, zeigt: Es tut sich etwas. \n\nKommunikation wird (wieder) zum strategischen Thema. Auch und gerade auf Plattformen wie LinkedIn.\n\n\nWir freuen uns, Teil dieser Diskussion zu sein. Denn wer Kommunikation zum Wachstumstreiber machen möchte, braucht mehr als netten Content.\n\n\nTausend Dank lieber Rolf Schröter, dass wir einen Teil zu dieser wichtigen Diskussion beitragen dürfen! 🙏 🙏 🙏 \n\nPS: Wer es noch nicht hat - am besten gleich ein Abo abschließen! Lohnt sich allein für die Kolumne \"Rolf räumt auf\" schon - mein wöchentliches must read!",
"url": "https://www.linkedin.com/posts/lisa-hipp_wv-ernennt-linkedin-als-schl%C3%BCsselplattform-activity-7404415479379062784-oPfx?utm_source=social_share_send&utm_medium=member_desktop_web&rcm=ACoAAGMGPDkBwcLGZeQ7c5M2vUoBf0G76hhvo1g",
"post_type": "repost",
"author": {
"first_name": "LISA",
"last_name": "HIPP",
"headline": "Co-Founder of MAKE IT MATTER I Strategische Kommunikationsberatung, PR & LinkedIn-Kompetenz für KMUs und Agenturen, die wachsen wollen I Interimsmanager I Speakerin I Autorin",
"username": "lisa-hipp",
"profile_url": "https://www.linkedin.com/in/lisa-hipp?miniProfileUrn=urn%3Ali%3Afsd_profile%3AACoAABoIg7cBNIMrNROuYH0Eu8SrT_RBD19epvU",
"profile_picture": "https://media.licdn.com/dms/image/v2/D4E03AQF_O5ImniRHpA/profile-displayphoto-shrink_800_800/B4EZWVGXr8GgAg-/0/1741963230810?e=1770249600&v=beta&t=PGFdRS4hMFX9posogdbIRWixVm-4odWkunMbSm3nbgc"
},
"stats": {
"total_reactions": 181,
"like": 126,
"support": 3,
"love": 10,
"insight": 4,
"celebrate": 38,
"funny": 0,
"comments": 66,
"reposts": 5
},
"media": {
"type": "image",
"url": "https://media.licdn.com/dms/image/v2/D4D22AQGYvef95dFTnw/feedshare-shrink_2048_1536/B4DZsHEo9mJ8Aw-/0/1765350216712?e=1770249600&v=beta&t=6YGJTfe6owLABZt8qDSL6isgFEjupW5orIhO23fVy30",
"images": [
{
"url": "https://media.licdn.com/dms/image/v2/D4D22AQGYvef95dFTnw/feedshare-shrink_2048_1536/B4DZsHEo9mJ8Aw-/0/1765350216712?e=1770249600&v=beta&t=6YGJTfe6owLABZt8qDSL6isgFEjupW5orIhO23fVy30",
"width": 1080,
"height": 1350
}
]
},
"pagination_token": "dXJuOmxpOmFjdGl2aXR5OjczMjQzMTgyODc0MjM1NTM1MzYtMTc0NjI1MzU1ODk1NA=="
},
{
"urn": {
"activity_urn": "7412408614184960000",
"share_urn": "7412408612888920064",
"ugcPost_urn": null
},
"full_urn": "urn:li:activity:7412408614184960000",
"posted_at": {
"date": "2026-01-01 09:25:29",
"relative": "2 weeks ago • Edited • Visible to anyone on or off LinkedIn",
"timestamp": 1767255929514
},
"text": "𝗪𝗘𝗥 𝗠𝗔𝗖𝗛𝗧 𝗠𝗜𝗧??? Ich habe entschieden, dass 2026 meine zweite Pubertät beginnt\n#MakeItMatter🚀\n\nHalleluja!\nEndlich hat das neue Jahr begonnen.\n\nMit meinen Freunden habe ich gestern beschlossen, dass ich das kommende Jahr jetzt mal völlig neu angehen werde:\n\nIch stelle mir einfach vor, ich wäre in meiner zweiten Pubertät!!!\n\nOk, ok, dieses Mal mit besserem Wein, einem kleinen Kontopuffer, 25 Jahren Berufserfahrung, besserem WLAN, aber mit dem gleichen Gefühl von damals:\n\n𝗗𝗘𝗥 𝗡𝗔̈𝗖𝗛𝗦𝗧𝗘 𝗟𝗘𝗕𝗘𝗡𝗦𝗔𝗕𝗦𝗖𝗛𝗡𝗜𝗧𝗧 𝗪𝗜𝗥𝗗 𝗗𝗘𝗥 𝗕𝗘𝗦𝗧𝗘!!!\n\nIch fühle es!\n\nAlles liegt vor mir und ich kann Pippi-Langstrumpf-mäßig einfach alles erreichen, à la:\n\n„𝘐𝘤𝘩 𝘩𝘢𝘣 𝘥𝘢𝘴 𝘯𝘰𝘤𝘩 𝘯𝘪𝘦 𝘨𝘦𝘮𝘢𝘤𝘩𝘵 𝘥𝘢𝘴 𝘬𝘢𝘯𝘯 𝘪𝘤𝘩 𝘣𝘦𝘴𝘵𝘪𝘮𝘮𝘵.“\n\n(PS: Wann haben wir das eigentlich verlernt?)\n\nIch finde die Ähnlichkeit zu meinem (nicht mehr pubertierenden) Sohn Lenny wirklich erstaunlich:\n\nEr ist genauso begeistert von der Idee, im Ausland zu studieren und sich etwas Eigenes, Großes aufzubauen, wie ich besessen von Lisas und meiner Make-It-Matter-Idee bin.\n\nJetzt sitzen wir auf dem Driverseat unseres Lebens und können Kommunikations-Burgen aufbauen: das, was wir am allerbesten können!\n\nWir mussten gestern bei dem Erste-und-Zweite-Pubertäts-Vergleich wirklich lachen:\n\nAuch die Hormonprobleme sind ähnlich. Nur habe ich meine im Griff bzw. hinter mir.\nLenny hat noch eine (aufregende) Reise vor sich.\n\nMuss man da nicht automatisch grinsen?\n\nDieses Grinsen werde ich mir für 2026 vornehmen. Ich werde mich öfter an den Sternenzauber meiner ersten Pubertät erinnern, an die unaufhaltsame Kraft, die Fröhlichkeit, den Mut, die Neugierde und die Ausdauer.\n\nUnd wisst ihr, worauf ich mich am meisten freue?\n\nAuf die Momente, in denen ich bei den Social Media Pirates - we are hiring we are hiring ins Büro komme und Max Anzile (der übrigens 20 Jahre jünger ist als ich) zu mir sagt:\n\n„Guten Morgen, Boomer!“\n…und frech grinst. Auf seine KI-Sessions, seine Ideen, neue Welten, Hummeln im Popo.\n\nUnd natürlich LISA HIPP, die allerallerallerbeste Geschäftspartnerin, die ich mir vorstellen kann.\n\n#ZamReinZamRaus\n\nSeid ihr dabei? 💫\nZweite Pubertät ab 2026? 💪\nVollgas? ✔️\nLebensfreude? 🫶\nKommunikation neu denken? ✔️\nJahr des Feuerpferdes? 🐎\n\nDann GALOPP!!!!!\n\nIch wünsche euch, dass all eure Träume und Wünsche in Erfüllung gehen und dass ihr den Mut habt, etwas dafür zu tun!\n\nIch habe die Tage dazu etwas Tolles gelesen:\n\n„Wenn die Sehnsucht größer ist als die Angst, wird Mut, Erfolg und Lebensfreude geboren.“\n\nIst das nicht schön?\n\nHappy New Year und happy neue Lebensphase(n)\n\nwünscht Euch\nChristina\n\n#MakeItMatter\n#Kommunikation\n#PublicRelations\n#LinkedInComms",
"url": "https://www.linkedin.com/posts/christinahildebrandt_makeitmatter-zamreinzamraus-makeitmatter-activity-7412408614184960000-QsHK?utm_source=social_share_send&utm_medium=member_desktop_web&rcm=ACoAAGMGPDkBwcLGZeQ7c5M2vUoBf0G76hhvo1g",
"post_type": "regular",
"author": {
"first_name": "Christina",
"last_name": "Hildebrandt",
"headline": "Co-Founder MAKE IT MATTER I Kommunikationsberatung, Interimsmanagement, PR & LinkedIn-Kompetenz für KMUs und Agenturen, die wachsen wollen",
"username": "christinahildebrandt",
"profile_url": "https://www.linkedin.com/in/christinahildebrandt?miniProfileUrn=urn%3Ali%3Afsd_profile%3AACoAABZ-U_wBolcXsKNwUaGEDfBwACvP5M5DkmQ",
"profile_picture": "https://media.licdn.com/dms/image/v2/D4D03AQFz-EjLfIatDw/profile-displayphoto-shrink_800_800/B4DZaIPVU5GwAg-/0/1746042443543?e=1770249600&v=beta&t=h61WiK5XGoOjPqfncwXZ6gtB_WvNT7HPs23MX79n3BE"
},
"stats": {
"total_reactions": 188,
"like": 139,
"support": 2,
"love": 32,
"insight": 1,
"celebrate": 11,
"funny": 3,
"comments": 84,
"reposts": 1
},
"media": {
"type": "image",
"url": "https://media.licdn.com/dms/image/v2/D4D22AQEGzsh77ciLGg/feedshare-shrink_1280/B4DZt4qVzPKoAs-/0/1767255927836?e=1770249600&v=beta&t=_QpVfCET4jWABpFB-eW45a_tCJzEcMPorza6cfjlFrU",
"images": [
{
"url": "https://media.licdn.com/dms/image/v2/D4D22AQEGzsh77ciLGg/feedshare-shrink_1280/B4DZt4qVzPKoAs-/0/1767255927836?e=1770249600&v=beta&t=_QpVfCET4jWABpFB-eW45a_tCJzEcMPorza6cfjlFrU",
"width": 1263,
"height": 1558
}
]
},
"pagination_token": "dXJuOmxpOmFjdGl2aXR5OjczMjQzMTgyODc0MjM1NTM1MzYtMTc0NjI1MzU1ODk1NA=="
},
{
"urn": {
"activity_urn": "7411645447343308802",
"share_urn": "7401690967386501120",
"ugcPost_urn": null
},
"full_urn": "urn:li:activity:7401866535658606592",
"posted_at": {
"date": "2025-12-30 06:52:56",
"relative": "1 month ago • Visible to anyone on or off LinkedIn",
"timestamp": 1767073976360
},
"text": "AKT 3 UNSER ANGEBOT FÜR DICH!\n\n\nFalls du Akt 1 und 2 von Christina und LISA gelesen hast, kennst du unser \"Warum\" schon.\n\nHeute geht es um die Frage, die dich vermutlich am meisten interessiert:\n\n\nWAS DU VON UNS BEKOMMST:\n\nKommunikation beginnt bei der Unternehmensstrategie und endet nicht beim Posting. Sie soll Klarheit schaffen, Vertrauen aufbauen und vor allem: WIRKEN!\nGenau darauf zahlen alle unsere Leistungen ein!\n\n\nUNSER ANGEBOT:\n\n1. Kommunikationsberatung\nWir entwickeln Unternehmens- und Markenkommunikation, Positionierung, Personal Branding, Social Media- und PR-Setups, die unmittelbar auf deine Unternehmensziele einzahlen. Alles andere ist Ressourcenverschwendung!\n\n2. Public Relations\nWir platzieren dich und dein Unternehmen in den wichtigsten Wirtschafts-, Fach- und Leitmedien.\n\n3. Trainings für Teams\nWir machen Teams zu Multiplikatoren. Storytelling, Content-Strategien, Social Media Enablement, PR x Plattformlogik und KI im Alltag. Praxisnah und verständlich, damit unser Wissen auch dann noch Wirkung hat, wenn wir schon längst nicht mehr an Bord sind.\n\n4. LinkedIn\nWir machen LinkedIn zum Business-Tool: für Sichtbarkeit, Recruiting, Leadgenerierung und Thought Leadership. Von Plattformstrategie über Corporate-Influencer-Programme bis zu Full Service inklusive Ghostwriting.\n\n5. Keynotes\nWir bringen unser Wissen auch auf die Bühne: mit Keynotes zu strategischer Kommunikation, Positionierung, Personal Branding, LinkedIn und der Zukunft von Business-Kommunikation.\n\n6. Interims-Management\nWenn der Druck steigt, springen wir ein: als erfahrene Sparringspartnerinnen auf C-Level für Strategie, Kommunikation, PR und Marke in bewegten Zeiten.\n\n\nWIE WIR ARBEITEN:\n\nHinter uns steht ein kuratiertes Netzwerk aus über 70 Expert:innen: von Text bis TikTok, von Snapchat über Employer Branding bis Meta Ads.\n\nDas heißt für dich:\n ein Ansprechpartner\n keine Overhead-Kosten\n kurze Wege\n Genau die Spezialist:innen, die DU gerade brauchst, und nicht die, die gerade ausgelastet werden müssen\n\nOder anders gesagt: höchstes Beratungsniveau plus hochwertige Umsetzungs-Werkbank, ohne den typische Wasserkopf.\n\n\nWAS DICH AUF DIESEM ACCOUNT ERWARTET:\n\nWenn du uns hier folgst, bekommst du in den nächsten Wochen unter anderem:\n➔ Einblicke, wie wir Kommunikationssysteme aufbauen, die in Budgetrunden bestehen\n➔ Beispiele, wie LinkedIn, PR und interne Kommunikation sinnvoll verzahnt werden\n➔ Perspektiven, wie KI dir in der Kommunikation wirklich hilft und wo sie nichts zu suchen hat\n➔ Fehler, die wir in 40+ Jahren Kommunikation immer wieder sehen und wie du sie vermeidest\n➔ Tools, Fragen und Denkmodelle, die du direkt in deinem Alltag umsetzen kannst\n\n\n🎁 UNSER GOODIE 🎁\n\nVor Weihnachten haben wir noch eine Überraschung für dich:\nAuf diesem Account verlosen wir ein exklusives Format mit uns, etwas, das es so nicht als Produkt auf der Website gibt.\n\nFür Menschen und Marken, die mehr wollen als Sichtbarkeit.\nMAKE IT MATTER!",
"url": "https://www.linkedin.com/posts/makeitmatter-communication_akt-3-unser-angebot-f%C3%BCr-dich-falls-du-activity-7401866535658606592-LsdM?utm_source=social_share_send&utm_medium=member_desktop_web&rcm=ACoAAGMGPDkBwcLGZeQ7c5M2vUoBf0G76hhvo1g",
"post_type": "repost",
"author": {
"first_name": "Make",
"last_name": "It Matter",
"headline": "461 followers",
"profile_url": "https://www.linkedin.com/company/makeitmatter-communication/posts"
},
"stats": {
"total_reactions": 173,
"like": 131,
"support": 3,
"love": 15,
"insight": 2,
"celebrate": 22,
"funny": 0,
"comments": 72,
"reposts": 4
},
"media": {
"type": "image",
"url": "https://media.licdn.com/dms/image/v2/D4E22AQEpSbAgeucWhg/feedshare-shrink_1280/B4EZrgWsBoKcAw-/0/1764700642073?e=1770249600&v=beta&t=GGSjT_vmz5CzAAPx--AyFkRcuVHNV9S7v87OrVDS2cY",
"images": [
{
"url": "https://media.licdn.com/dms/image/v2/D4E22AQEpSbAgeucWhg/feedshare-shrink_1280/B4EZrgWsBoKcAw-/0/1764700642073?e=1770249600&v=beta&t=GGSjT_vmz5CzAAPx--AyFkRcuVHNV9S7v87OrVDS2cY",
"width": 1280,
"height": 1599
}
]
},
"pagination_token": "dXJuOmxpOmFjdGl2aXR5OjczMjQzMTgyODc0MjM1NTM1MzYtMTc0NjI1MzU1ODk1NA=="
},
{
"urn": {
"activity_urn": "7411644491889246208",
"share_urn": "7406801427307589632",
"ugcPost_urn": null
},
"full_urn": "urn:li:activity:7407309899174486016",
"posted_at": {
"date": "2025-12-30 06:49:08",
"relative": "1 month ago • Edited • Visible to anyone on or off LinkedIn",
"timestamp": 1767073748562
},
"text": "GEWINNE EIN EXKLUSIVES STRATEGIE-SPARRING\n\nDu hast ein Kommunikations-Thema, bei dem du feststeckst? Dir fehlt die Idee, wie Du deine Marketing Botschaften strategisch mit deinen Unternehmenszielen verlinkst? Dein Social Media Team kommt immer wieder mit Vorschlägen wie dem Welt-Strumpfhosen-Tag?\n\nSprich, dir fehlt Klarheit in der Kommunikation deiner Marke, auf LinkedIn, in deiner Positionierung oder im Bereich PR?\n\nDann kommt hier DEIN Weihnachtsgeschenk:\n\nWir verlosen 1x gratis Strategie-Booster-Call:\n • 60 Minuten Sparring mit Christina Hildebrandt und LISA HIPP, den Gründerinnen von Make It Matter\n • Fokus: Kommunikationsstrategie, Kommunikation als Wachstumstreiber, LinkedIn, Thought Leadership, PR, etc.\n \nWas das Format besonders macht:\n Es ist nicht buchbar 🤫 \n Wir gehen 60 Minuten mit vollem Fokus auf deine individuellen Needs ein (Deep dive und Tabula Rasa)\n Direkte Ideen und konkrete Lösungen\n\nLUST? \n\nSo kannst Du teilnehmen:\n • Folge unserem Account Make It Matter\n • Melde dich zu unserem Newsletter an\n • Teilnahmeschluss: 24.12 🎄 \n\n\n✨ ✨ ✨ ✨ ✨ ✨ \n\nUm Teilzunehmen einmal auf unsere Website nach unten scrollen und im Newsletter eintragen\nhttps://make-it-matter.de\n\n✨ ✨ ✨ ✨ ✨ ✨ \n\n\nWir drücken die Daumen!",
"url": "https://www.linkedin.com/posts/makeitmatter-communication_gewinne-ein-exklusives-strategie-sparring-activity-7407309899174486016-em8s?utm_source=social_share_send&utm_medium=member_desktop_web&rcm=ACoAAGMGPDkBwcLGZeQ7c5M2vUoBf0G76hhvo1g",
"post_type": "repost",
"author": {
"first_name": "Make",
"last_name": "It Matter",
"headline": "461 followers",
"profile_url": "https://www.linkedin.com/company/makeitmatter-communication/posts"
},
"stats": {
"total_reactions": 142,
"like": 113,
"support": 2,
"love": 12,
"insight": 1,
"celebrate": 14,
"funny": 0,
"comments": 153,
"reposts": 6
},
"media": {
"type": "image",
"url": "https://media.licdn.com/dms/image/v2/D4E22AQGleDz_WX7P0g/feedshare-shrink_1280/B4EZso.ojCHoAs-/0/1765919070643?e=1770249600&v=beta&t=8sPMf2fu4D4XmNbtx6zTWQ28Havbe1lb6U8tYlbELNs",
"images": [
{
"url": "https://media.licdn.com/dms/image/v2/D4E22AQGleDz_WX7P0g/feedshare-shrink_1280/B4EZso.ojCHoAs-/0/1765919070643?e=1770249600&v=beta&t=8sPMf2fu4D4XmNbtx6zTWQ28Havbe1lb6U8tYlbELNs",
"width": 1280,
"height": 1600
}
]
},
"pagination_token": "dXJuOmxpOmFjdGl2aXR5OjczMjQzMTgyODc0MjM1NTM1MzYtMTc0NjI1MzU1ODk1NA=="
},
{
"urn": {
"activity_urn": "7409072824025534464",
"share_urn": "7398196790556237825",
"ugcPost_urn": null
},
"full_urn": "urn:li:activity:7409072824025534464",
"posted_at": {
"date": "2025-12-23 04:30:15",
"relative": "0 months ago • Edited • Visible to anyone on or off LinkedIn",
"timestamp": 1766460615164
},
"text": "#agediversity #WildeHilde\nF R Ü H E R: Dauerfeuer, Missionseifer, Dauerdruck, Empörung, Verzweiflung, unendliche Diskussionen, ständig „noch schnell eins oben drauf“. Perfektionismus als Pflichtprogramm. Ungeduld, \nVollgas, All-In… immer, überall. \n\nUnd dieses eine, alte Mantra:\n„Wenn ichs nicht rette … wer dann 🤷‍♀️?“\n\n\nH E U T E: Erfahrung, Prioritäten, Netzwerk, KI-Erleichterung, Abkürzungen, 110 % bewusst eingesetzt, Herzblut mit Schutzfilter, \nKlarheit, Ja zum Nein, Beobchten & Schweigen, Qualität, Ruhe & Begeisterung, Neugierde\nAnpassungsfähigkeit. Und: \nHUMOR, VERTRAUEN, INTUITION\n \nUnd die klare Erkenntnis:\n\n„Ich rette heute nichts mehr und kämpfe nichts mehr durch gegen mühsamen Widerstand. 🤷‍♀️“\n\nÄlter werden? \nWunderbar!\n\nBitte mehr davon!!!\nChristina\n\nPS. Danke 2025: Das erste Mal Angst-entfesselte Firmen-Gründerin mit meiner Geschäftspartnerin LISA HIPP und unsere Partner Max Anzile (1+1+1=10)🤷‍♀️\n\nDanke 2024\nDanke www.make-it-matter.de\nDanke #Wunderlisa🤍\nDanke Max Anzile … ich bin soooo gerne Dein Boomer!!! So so gerne🫶",
"url": "https://www.linkedin.com/posts/christinahildebrandt_agediversity-wildehilde-wunderlisa-activity-7409072824025534464-uYb9?utm_source=social_share_send&utm_medium=member_desktop_web&rcm=ACoAAGMGPDkBwcLGZeQ7c5M2vUoBf0G76hhvo1g",
"post_type": "regular",
"author": {
"first_name": "Christina",
"last_name": "Hildebrandt",
"headline": "Co-Founder MAKE IT MATTER I Kommunikationsberatung, Interimsmanagement, PR & LinkedIn-Kompetenz für KMUs und Agenturen, die wachsen wollen",
"username": "christinahildebrandt",
"profile_url": "https://www.linkedin.com/in/christinahildebrandt?miniProfileUrn=urn%3Ali%3Afsd_profile%3AACoAABZ-U_wBolcXsKNwUaGEDfBwACvP5M5DkmQ",
"profile_picture": "https://media.licdn.com/dms/image/v2/D4D03AQFz-EjLfIatDw/profile-displayphoto-shrink_800_800/B4DZaIPVU5GwAg-/0/1746042443543?e=1770249600&v=beta&t=h61WiK5XGoOjPqfncwXZ6gtB_WvNT7HPs23MX79n3BE"
},
"stats": {
"total_reactions": 268,
"like": 188,
"support": 29,
"love": 13,
"insight": 3,
"celebrate": 25,
"funny": 10,
"comments": 81,
"reposts": 3
},
"media": {
"type": "image",
"url": "https://media.licdn.com/dms/image/v2/D4D22AQFNSuPF7v-ciA/feedshare-shrink_2048_1536/B4DZqusxbDJMAw-/0/1763867566273?e=1770249600&v=beta&t=5h5VmVC_at1s5E0-35p7G7y-YsblnmFt36y2D_MRVtw",
"images": [
{
"url": "https://media.licdn.com/dms/image/v2/D4D22AQFNSuPF7v-ciA/feedshare-shrink_2048_1536/B4DZqusxbDJMAw-/0/1763867566273?e=1770249600&v=beta&t=5h5VmVC_at1s5E0-35p7G7y-YsblnmFt36y2D_MRVtw",
"width": 1283,
"height": 1226
}
]
},
"pagination_token": "dXJuOmxpOmFjdGl2aXR5OjczMjQzMTgyODc0MjM1NTM1MzYtMTc0NjI1MzU1ODk1NA=="
}
]

26
requirements.txt Normal file
View File

@@ -0,0 +1,26 @@
# Core Dependencies
python-dotenv==1.0.0
pydantic==2.5.0
pydantic-settings==2.1.0
# AI & APIs
openai==1.54.0
apify-client==1.7.0
# Database
supabase==2.9.1
# TUI
textual==0.85.0
rich==13.7.0
# Utilities
tenacity==8.2.3
loguru==0.7.2
httpx==0.27.0
# Web Frontend
fastapi==0.115.0
uvicorn==0.32.0
jinja2==3.1.4
python-multipart==0.0.9

16
run_web.py Normal file
View File

@@ -0,0 +1,16 @@
#!/usr/bin/env python3
"""Run the web frontend."""
import uvicorn
if __name__ == "__main__":
print("\n" + "=" * 50)
print(" LinkedIn Posts Dashboard")
print(" http://localhost:8000")
print("=" * 50 + "\n")
uvicorn.run(
"src.web.app:app",
host="0.0.0.0",
port=8000,
reload=True
)

2
src/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
"""LinkedIn Workflow System - Main package."""
__version__ = "1.0.0"

20
src/agents/__init__.py Normal file
View 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
View 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
View 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"""

View 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

View 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)

View 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
View 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

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,4 @@
"""Scraper module."""
from src.scraper.apify_scraper import LinkedInScraper, scraper
__all__ = ["LinkedInScraper", "scraper"]

View 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
View 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
View 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
View File

@@ -0,0 +1 @@
"""Web frontend package."""

View 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
View 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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

View 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>

View 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 %}

View 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 %}

View 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>

View 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 %}

File diff suppressed because it is too large Load Diff

View 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 %}

View 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 %}

View 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 %}

View 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
View 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>

View 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 %}

View 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 %}

View 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>

View 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 %}

File diff suppressed because it is too large Load Diff

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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>

View 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>

View 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 %}

View 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 %}

View 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>

View 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>

View 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 %}

View 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 %}

View 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 %}

View 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
View 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
View 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
View 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}

638
workflow_now.json Normal file

File diff suppressed because one or more lines are too long