🚀 BSN-Chatsystem v2.1 — Implementierungsplan (extern geprüft)

Datum14. Juli 2026
Version5.1 — Externes Review eingearbeitet
AutorCody (Hermes Agent), mit Korrekturen von externem Prüfer
Repositorygit@git.datenhimmel.work:danielkrause/BSN-Chatsystem.git
Status✅ Umsetzungsreif

📑 Inhalt

  1. Ziel & Rahmenbedingungen
  2. Server
  3. Architektur
  4. Datenfluss
  5. Komponenten
  6. Datenbank
  7. API-Endpunkte
  8. Queue-System
  9. LLM (OpenRouter Mistral 4)
  10. Cloudflare-Cache + Access
  11. Installation
  12. Konfigurationsdateien
  13. systemd-Services
  14. Monitoring
  15. Sicherheit
  16. Backup & Recovery
  17. DSGVO-Löschroutine
  18. Go-Live
  19. Checkliste (10 Punkte)
  20. Fallback
  21. Code-Änderungen
  22. Skalierung (1000/h)
  23. Zukunft
  24. Änderungsprotokoll v5→v5.1

1. Ziel & Rahmenbedingungen

Ziel: Das BSN-Chatsystem (chat.datenhimmel.work) 1:1 funktional auf dedizierten dogado-Server migrieren, Architektur produktionsreif.

RahmenbedingungWert
Funktionsumfang100% identisch zum aktuellen System
LLMOpenRouter → Mistral 4 (europäisch, DSGVO)
TTSDeaktiviert (später optional)
QueueEigener systemd-Service (nicht Daemon-Thread)
Skalierungsziel~1000 Nutzer/h
Admin-SchutzCloudflare Access (vor Go-Live)
BackupOffsite via rsync zum Hermes-Server
Service-Userbsn (nicht root)

2. Server

EigenschaftWert
IP89.22.110.107 (dogado GmbH)
CPUXeon E5-2680 v3, 6 Cores @ 2.50 GHz
RAM12 GB
Disk492 GB SSD
OS ZielUbuntu 24.04 LTS minimal (KEIN Plesk)
Offene PortsNUR 22 (SSH), 80, 443

3. Architektur

┌──────────────────────────────────────────────────┐
│              🌍 Cloudflare                        │
│  Cache Rules: / + /beitrag/* → 5min              │
│  Bypass: /api/*, /webhook, /admin, /telegram     │
│  Access (OTP): /admin/*                          │
│  DDoS, WAF, weltweit Edge                        │
└────────┬─────────────────────────────────────────┘
         │ cloudflared Tunnel → localhost:80
┌────────▼─────────────────────────────────────────┐
│  nginx Reverse-Proxy :80                         │
│  limit_req_zone im HTTP-Kontext                  │
│  /static/* → direkt von Disk                     │
│  /media/*  → direkt von Disk                     │
│  proxy_pass → gunicorn :8000                     │
│  KEIN nginx-Cache (macht Cloudflare)             │
└────────┬─────────────────────────────────────────┘
         │ HTTP :8000
┌────────▼──────────┐    ┌─────────────────────────┐
│  bsn-chatbot      │    │  bsn-worker             │
│  gunicorn 4w×2t   │    │  eigener systemd-       │
│  wsgi:app         │    │  Service                │
│  User: bsn        │    │  pollt submission_queue │
│  Flask-Routen     │    │  Whisper 1× geladen     │
└────────┬──────────┘    │  kein Recycling mid-    │
         │               │  Verarbeitung           │
         │               └───────────┬─────────────┘
         │                           │
┌────────▼───────────────────────────▼─────────────┐
│              SQLite (WAL-Mode)                   │
│  bsn_intake.db                                   │
└──────────────────────────────────────────────────┘

Extern: OpenRouter API (Mistral 4), Meta Cloud API (WhatsApp send)

4. Datenfluss

READ (~95%)

User → Cloudflare Edge (Cache HIT, 85%) → <50ms → FERTIG
User → Cloudflare (Cache MISS) → nginx → gunicorn → SQLite → HTML
  → Cloudflare cached für 5min → nächster Request = HIT
⚠️ Flask DARF KEIN Set-Cookie auf / + /beitrag/* senden!

WRITE (~5%)

WhatsApp → Meta → POST /whatsapp/webhook → nginx → gunicorn:
  1. Signatur-Prüfung (HMAC) → 403 bei Fehler
  2. INSERT INTO submission_queue → 200 OK in <10ms

bsn-worker (eigener Prozess, pollt alle 2s):
  1. SELECT pending LIMIT 3
  2. UPDATE status=processing
  3. Media-Download + Transkription (Whisper) + LLM-Triage
  4. INSERT INTO submissions
  5. UPDATE status=completed

5. Komponenten

KomponenteTechnologieZweck
OSUbuntu 24.04 LTSMinimal, kein Plesk
Python3.13 (deadsnakes PPA)
WSGIgunicorn 23.x4 Worker × 2 Threads, User: bsn
Reverse-Proxynginx 1.24+Buffering, Static, Rate-Limit
FrameworkFlask 3.xapp.py (angepasst)
DBSQLite 3.45+ (WAL)bsn_intake.db
Queue-WorkerEigener systemd-Servicebsn-worker.service
LLMOpenRouter Mistral 4Triage + Chat-Assistant
Transkriptionfaster-whisper base1× geladen, kein Reload
BildPillow 11.xResize 1920×1080
TunnelcloudflaredGleiche Credentials auf beiden Servern
Prozesssystemd3 Services: nginx, bsn-chatbot, bsn-worker
FirewallufwNur 22, 80, 443

6. Datenbank

submissions (Haupttabelle)

CREATE TABLE submissions (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    channel TEXT NOT NULL,                -- whatsapp, telegram, web
    sender_id TEXT NOT NULL,
    sender_name TEXT,
    type TEXT NOT NULL,                   -- text, image, audio, video
    content TEXT,
    media_path TEXT,
    media_publish_flags TEXT,
    status TEXT DEFAULT 'new',            -- new, done, published, flagged, gdpr_deleted
    category TEXT,
    summary TEXT,
    publish_name TEXT,
    halle TEXT,
    spiel_titel TEXT,
    display_text TEXT,
    triage_tier INTEGER,                  -- 1=🟢, 2=🟡, 3=🔴
    triage_reasons TEXT,                  -- JSON-Array
    triage_at TEXT,
    auto_response_sent INTEGER DEFAULT 0,
    public INTEGER DEFAULT 0,
    published_at TEXT,
    deleted_at TEXT,                      -- Soft-Delete für DSGVO-Löschroutine
    created_at TEXT DEFAULT (datetime('now','localtime')),
    -- weitere Felder: email, phone, likes, views
);

submission_queue (Worker-Queue)

CREATE TABLE submission_queue (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    channel TEXT NOT NULL,
    message_id TEXT UNIQUE NOT NULL,       -- WhatsApp msg ID / Telegram update_id
    payload TEXT NOT NULL,                 -- JSON: Webhook-Body
    status TEXT DEFAULT 'pending',         -- pending, processing, completed, failed
    attempt_count INTEGER DEFAULT 0,
    last_error TEXT,
    created_at TEXT DEFAULT (datetime('now','localtime')),
    updated_at TEXT DEFAULT (datetime('now','localtime'))
);
CREATE INDEX idx_queue_status ON submission_queue(status);

7. API-Endpunkte

MethodePfadFunktionCloudflareSet-Cookie?
GET/Frontend-Feed (Masonry)Cache 5min❌ NEIN
GET/beitrag/<id>Beitrag-DetailseiteCache 5min❌ NEIN
GET/healthHealth-Check (schlank)BYPASS
GET/POST/admin/*Admin-DashboardBYPASS + Access OTP✅ Login-Cookie
POST/api/submitWeb-FormularBYPASS
POST/api/chatChat-AssistantBYPASS
GET/POST/whatsapp/webhookWhatsApp-IntakeBYPASS
POST/telegram/webhookTelegram-IntakeBYPASS

8. Queue-System

✅ v5.1-Fix: Queue-Worker ist ein eigener systemd-Service (bsn-worker.service), kein Daemon-Thread in gunicorns __main__. Whisper-Modell wird 1× beim Start geladen und nicht mitten in der Verarbeitung recycled.

Queue-Flow

POST /whatsapp/webhook → HMAC-Prüfung → INSERT submission_queue → 200 OK (<10ms)

bsn-worker (eigener systemd-Prozess):
  1. Whisper-Modell laden (1× beim Start)
  2. WHILE True:
     SELECT ... WHERE status='pending' LIMIT 3
     FOR EACH:
       UPDATE status='processing'
       → Media-Download
       → Transkription (faster-whisper, Modell bereits geladen)
       → LLM-Triage (OpenRouter)
       → INSERT INTO submissions
       → UPDATE status='completed'
     SLEEP 2

Idempotenz

message_id UNIQUE verhindert Doppelverarbeitung. Meta wiederholt Webhooks? → INSERT OR IGNORE.

Fehlertoleranz

SzenarioVerhalten
Worker crashedsystemd startet neu. Whisper wird neu geladen. Einträge mit status=processing >10min → Reset auf pending.
Transkription fehlgeschlagenstatus=failed, attempt_count++. Nach 3 Versuchen → endgültig failed.
LLM nicht erreichbarEintrag bleibt pending, Worker retried beim nächsten Poll.
Server-NeustartAlle Zustände in SQLite persistent. Worker setzt fort.

9. LLM (OpenRouter Mistral 4)

# .env:
OPENROUTER_API_KEY=sk-or-v1-...n
# API: POST https://openrouter.ai/api/v1/chat/completions
# Modell: "mistralai/mistral-large"
FunktionCalls/hTokens/Call
Triage (🟢🟡🔴)~50~500 in, ~200 out
Zusammenfassung~30~800 in, ~150 out
Chat-Assistant~20~2000 in, ~300 out
Total~100~130k Tokens/h

Kosten: ~$0.26/h bei Volllast.

10. Cloudflare Cache + Access

✅ v5.1-Fix: Cache Rules als expliziter Installationsschritt. Ohne diese Regeln cached Cloudflare KEIN HTML — die gesamte Skalierungsrechnung bricht zusammen.

10.1 Cache Rules (Cloudflare Dashboard → Caching → Cache Rules)

RuleURI PathCache-StatusEdge TTL
Frontend + Details/* + /beitrag/*Eligible for Cache5 min
API + Webhooks + Admin/api/* + /whatsapp/* + /telegram/* + /admin/*Bypass Cache
Statische Assets/static/*Eligible for Cache365 Tage

10.2 Kein Set-Cookie auf öffentlichen Routen

⚠️ Flask darf auf / und /beitrag/* KEINE Cookies setzen. Sonst überspringt Cloudflare den Cache (Cookie = private response). Ggf. @app.after_request prüfen und Session-Cookies nur für /admin/* setzen.

10.3 Cloudflare Access für /admin/*

✅ v5.1-Fix: Cloudflare Access wird VOR Go-Live eingerichtet, nicht als Zukunftsfeature.
Cloudflare Dashboard → Zero Trust → Access → Applications → Add:
  Name: "BSN Admin"
  Domain: chat.datenhimmel.work
  Path: /admin/*
  Policy: Allow, One-time PIN → E-Mail an daniel@brettspiel-news.de

So ist /admin/* ab Go-Live durch OTP geschützt. Kein Passwort im Flask nötig.

11. Installation (14 Schritte)

Schritt 1: OS

# dogado-Kundencenter → Neuinstallation → Ubuntu 24.04 LTS minimal
# KEIN Plesk. Root-Passwort selbst setzen.

Schritt 2: Basis

apt update && apt upgrade -y
apt install -y python3.13 python3.13-venv python3.13-dev \
    git nginx ufw fail2ban curl unzip build-essential restic

Schritt 3: Service-User

✅ v5.1-Fix: Eigener User bsn statt root.
useradd --system --home /opt/bsn-chatbot --shell /bin/bash bsn
mkdir -p /opt/bsn-chatbot/{src,data,logs,media,backups}
chown -R bsn:bsn /opt/bsn-chatbot

Schritt 4: Firewall

ufw default deny incoming
ufw allow 22/tcp
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable

Schritt 5: venv

su - bsn
python3.13 -m venv /opt/bsn-chatbot/venv
source /opt/bsn-chatbot/venv/bin/activate
pip install --upgrade pip wheel

Schritt 6: Repo

cd /opt/bsn-chatbot/src
git clone git@git.datenhimmel.work:danielkrause/BSN-Chatsystem.git .
pip install -r requirements.txt
pip install faster-whisper gunicorn

Schritt 7: .env

cat > /opt/bsn-chatbot/src/.env << 'EOF'
DATABASE_PATH=/opt/bsn-chatbot/data/bsn_intake.db
MEDIA_DIR=/opt/bsn-chatbot/data/media
META_TOKEN=EAAx.....n
OPENR....I_KEY=sk-or-v1-...n
QUEUE_POLL_INTERVAL=2
QUEUE_BATCH_SIZE=3
TRIAGE_DELAY_SECONDS=360
EOF
chmod 600 /opt/bsn-chatbot/src/.env

Schritt 8: Daten migrieren

scp hermes@185.162.249.159:~/workspace/bsn-chatbot/bsn_intake.db /opt/bsn-chatbot/data/
scp -r hermes@185.162.249.159:~/workspace/bsn-chatbot/media/* /opt/bsn-chatbot/data/media/
chown -R bsn:bsn /opt/bsn-chatbot/data

Schritt 9: cloudflared (EINMAL)

✅ v5.1-Fix: Gleiche Tunnel-Credentials für beide Server. Tunnel wird EINMAL erstellt, Credentials auf beide Server kopiert.
wget -q https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64
chmod +x cloudflared-linux-amd64
mv cloudflared-linux-amd64 /usr/local/bin/cloudflared

# Login (einmalig, Browser):
cloudflared tunnel login

# Tunnel ERSTELLEN:
cloudflared tunnel create bsn-chat

# DNS-Route (CNAME):
cloudflared tunnel route dns bsn-chat chat.datenhimmel.work

# Tunnel-ID notieren (z.B. e84dedca-...)

Schritt 10: cloudflared Config

cat > ~/.cloudflared/config.yml << 'CFEOF'
tunnel: <TUNNEL_ID>
credentials-file: ~/.cloudflared/<TUNNEL_ID>.json
ingress:
  - hostname: chat.datenhimmel.work
    service: http://localhost:80
  - service: http_status:404
CFEOF

cloudflared service install
systemctl enable --now cloudflared

Schritt 11: Tunnel-Credentials auf Ketchup kopieren

✅ v5.1-Fix: Gleiche Credentials auf beiden Servern — Rollback ist DNS-Umschaltung, kein Tunnel-Wechsel.
# Auf dogado:
scp ~/.cloudflared/<TUNNEL_ID>.json hermes@185.162.249.159:~/.cloudflared/
scp ~/.cloudflared/cert.pem hermes@185.162.249.159:~/.cloudflared/

# Auf Ketchup:
cp <TUNNEL_ID>.json ~/workspace/bsn-chatbot/
# config.yml auf Ketchup auf localhost:5002 zeigen lassen

Schritt 12: Cloudflare Cache Rules

Cloudflare Dashboard → Caching → Cache Rules → Create Rule:
  Field: URI Path, Operator: contains, Value: /beitrag/
  (plus separate rule for /static/*)
  → Eligible for Cache, Edge TTL: 5 min / 365 days

Schritt 13: Cloudflare Access

Cloudflare Dashboard → Zero Trust → Access → Applications → Add:
  Application: BSN Admin
  Domain: chat.datenhimmel.work
  Path: /admin/*
  → Policy: Allow, One-time PIN → daniel@brettspiel-news.de

Schritt 14: Flask prüfen — kein Set-Cookie auf /

# In app.py prüfen: after_request setzt keine Cookies auf öffentlichen Routen
grep -n "set_cookie\|Set-Cookie\|session\[" /opt/bsn-chatbot/src/app.py
# Falls Cookies gesetzt werden → auf /admin/* beschränken

12. Konfigurationsdateien

12.1 nginx: /etc/nginx/sites-available/bsn-chatbot

✅ v5.1-Fix: limit_req_zone im HTTP-Kontext (nicht Server), nginx-Cache-Direktiven entfernt (macht Cloudflare), Webhook-Pfade korrigiert.
# /etc/nginx/nginx.conf (HTTP-Kontext):
http {
    limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s;
    limit_req_zone $binary_remote_addr zone=webhook:10m rate=10r/s;
    # ...
}

# /etc/nginx/sites-available/bsn-chatbot:
server {
    listen 80;
    server_name _;   # cloudflared verbindet per IP

    client_max_body_size 50M;

    # Static Files
    location /static/ {
        alias /opt/bsn-chatbot/src/static/;
        expires 365d;
        add_header Cache-Control "public, immutable";
    }

    # Media Files
    location /media/ {
        alias /opt/bsn-chatbot/data/media/;
        expires 7d;
    }

    # Webhooks — exakte Pfade
    location ~ ^/(whatsapp|telegram)/webhook {
        proxy_pass http://127.0.0.1:8000;
        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;
        limit_req zone=webhook burst=20 nodelay;
    }

    # Admin
    location /admin {
        proxy_pass http://127.0.0.1:8000;
        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_buffering off;
    }

    # API
    location /api/ {
        proxy_pass http://127.0.0.1:8000;
        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;
        limit_req zone=api burst=20 nodelay;
    }

    # Frontend (KEIN nginx-Cache, macht Cloudflare)
    location / {
        proxy_pass http://127.0.0.1:8000;
        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;
    }
}

12.2 gunicorn: /opt/bsn-chatbot/gunicorn.conf.py

bind = "127.0.0.1:8000"
workers = 4
threads = 2
worker_class = "gthread"
timeout = 120
graceful_timeout = 30
keepalive = 5
user = "bsn"
group = "bsn"
accesslog = "/opt/bsn-chatbot/logs/gunicorn-access.log"
errorlog = "/opt/bsn-chatbot/logs/gunicorn-error.log"
loglevel = "info"
proc_name = "bsn-chatbot"
max_requests = 1000
max_requests_jitter = 100

13. systemd-Services

13.1 bsn-chatbot.service (gunicorn)

[Unit]
Description=BSN Chatbot — gunicorn WSGI
After=network.target

[Service]
Type=notify
User=bsn
Group=bsn
WorkingDirectory=/opt/bsn-chatbot/src
EnvironmentFile=/opt/bsn-chatbot/src/.env
ExecStart=/opt/bsn-chatbot/venv/bin/gunicorn --config /opt/bsn-chatbot/gunicorn.conf.py app:app
ExecReload=/bin/kill -s HUP $MAINPID
Restart=always
RestartSec=5
PrivateTmp=true
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/bsn-chatbot/data /opt/bsn-chatbot/logs /opt/bsn-chatbot/backups
ReadOnlyPaths=/opt/bsn-chatbot/src

[Install]
WantedBy=multi-user.target

13.2 bsn-worker.service (Queue)

✅ v5.1-Fix: Worker als eigener systemd-Service — kein Daemon-Thread unter gunicorn.
[Unit]
Description=BSN Queue Worker — Submission-Verarbeitung
After=bsn-chatbot.service
Wants=bsn-chatbot.service

[Service]
Type=simple
User=bsn
Group=bsn
WorkingDirectory=/opt/bsn-chatbot/src
EnvironmentFile=/opt/bsn-chatbot/src/.env
ExecStart=/opt/bsn-chatbot/venv/bin/python worker.py
Restart=always
RestartSec=10
PrivateTmp=true
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/bsn-chatbot/data /opt/bsn-chatbot/logs
ReadOnlyPaths=/opt/bsn-chatbot/src

[Install]
WantedBy=multi-user.target

13.3 worker.py (neue Datei)

#!/usr/bin/env python3
"""Standalone Queue-Worker — eigener Prozess, nicht unter gunicorn."""
import os, sys, time, json, sqlite3
sys.path.insert(0, '/opt/bsn-chatbot/src')

from dotenv import load_dotenv
load_dotenv()

DB = os.environ['DATABASE_PATH']
POLL = int(os.environ.get('QUEUE_POLL_INTERVAL', 2))
BATCH = int(os.environ.get('QUEUE_BATCH_SIZE', 3))

# Whisper 1× laden (kein Recycling mitten in der Verarbeitung)
from faster_whisper import WhisperModel
model = WhisperModel("base", device="cpu", compute_type="int8")

def get_db():
    db = sqlite3.connect(DB)
    db.row_factory = sqlite3.Row
    db.execute("PRAGMA journal_mode=WAL")
    db.execute("PRAGMA busy_timeout=5000")
    return db

def process_one(db, row):
    # → Media-Download, Transkription, LLM-Triage, DB-Insert
    # (Implementierung aus queue_worker.py übernehmen)
    pass

def main():
    print("[worker] Gestartet, Whisper-Modell geladen.", flush=True)
    db = get_db()
    while True:
        try:
            rows = db.execute(
                "SELECT * FROM submission_queue WHERE status='pending' ORDER BY created_at LIMIT ?",
                (BATCH,)).fetchall()
            for row in rows:
                db.execute("UPDATE submission_queue SET status='processing' WHERE id=?", (row['id'],))
                db.commit()
                try:
                    process_one(db, row)
                    db.execute("UPDATE submission_queue SET status='completed' WHERE id=?", (row['id'],))
                except Exception as e:
                    db.execute(
                        "UPDATE submission_queue SET status='failed', last_error=?, attempt_count=attempt_count+1 WHERE id=?",
                        (str(e)[:500], row['id']))
                db.commit()
        except Exception as e:
            print(f"[worker] Fehler: {e}", flush=True)
        time.sleep(POLL)

if __name__ == '__main__':
    main()

13.4 Aktivierung

systemctl daemon-reload
systemctl enable --now bsn-chatbot bsn-worker nginx
# cloudflared ist bereits aktiv (Schritt 10)

14. Monitoring

KomponenteLog
gunicorn Access/opt/bsn-chatbot/logs/gunicorn-access.log
gunicorn Error/opt/bsn-chatbot/logs/gunicorn-error.log
Workerjournalctl -u bsn-worker
Flaskjournalctl -u bsn-chatbot
nginx/var/log/nginx/access.log

/health (abgespeckt)

✅ v5.1-Fix: /health wurde entschlackt — nur noch status, db, whatsapp_configured.
GET /health → {"status":"ok","db":"bsn_intake.db","whatsapp_configured":true}

Watchdog (Hermes-Server)

# Cronjob alle 5min:
curl -s -o /dev/null -w "%{http_code}" https://chat.datenhimmel.work/health | grep -q 200
# Bei Fehler → Telegram an Daniel

15. Sicherheit

AspektMaßnahme
FirewallNur 22, 80, 443. Flask (8000) nur localhost.
SSHKey-basiert, kein Passwort
WebhookHMAC-SHA256 Signatur-Prüfung
Rate-Limitnginx: 30 r/s (api), 10 r/s (webhook)
SQL-InjectionParameterisierte Queries
XSSJinja2 Autoescaping
Secrets.env, chmod 600, nicht in Git
systemdProtectSystem=strict, NoNewPrivileges
AdminCloudflare Access OTP (E-Mail)
Service-Userbsn (nicht root)

16. Backup & Recovery

✅ v5.1-Fix: Offsite-Backup via rsync zum Hermes-Server (185.162.249.159) statt nur lokal.

16.1 Backup-Script (/opt/bsn-chatbot/backup.sh)

#!/bin/bash
set -e
BACKUP_DIR=/opt/bsn-chatbot/backups
HERMES="hermes@185.162.249.159:/home/hermes/backups/bsn-chatbot"
DATE=$(date +%Y%m%d-%H%M)

mkdir -p $BACKUP_DIR

# Lokales DB-Backup
sqlite3 /opt/bsn-chatbot/data/bsn_intake.db \
    ".backup $BACKUP_DIR/bsn_intake_$DATE.db"

# Offsite (zum Hermes-Server)
rsync -avz --delete $BACKUP_DIR/ $HERMES/db/
rsync -avz /opt/bsn-chatbot/data/media/ $HERMES/media/

# Rotation: 72 lokale Backups behalten
ls -t $BACKUP_DIR/bsn_intake_*.db | tail -n +73 | xargs rm -f

16.2 Cron für Backup

# /etc/cron.d/bsn-backup
0 * * * * bsn /opt/bsn-chatbot/backup.sh

16.3 Recovery

LAST=$(ls -t /opt/bsn-chatbot/backups/bsn_intake_*.db | head -1)
systemctl stop bsn-chatbot bsn-worker
cp $LAST /opt/bsn-chatbot/data/bsn_intake.db
systemctl start bsn-chatbot bsn-worker

17. DSGVO-Löschroutine

✅ v5.1-Fix: Automatische Löschroutine ist Teil von v1, nicht Zukunftsfeature.

17.1 WhatsApp: „Meine Daten löschen"

# Bereits implementiert in app.py:
# Nutzer sendet "Meine Daten löschen" → alle Submissions dieser Nummer:
# UPDATE submissions SET deleted_at=CURRENT_TIMESTAMP, status='gdpr_deleted'
# Medien von Platte löschen
# Auto-Reply: Bestätigung

17.2 Web: Referenz-ID

# Bereits implementiert:
# Nach Web-Submit: Referenz-ID anzeigen + Kopier-Button
# Löschung aktuell via Kontaktaufnahme Redaktion
# ⚠️ Selbstbedienungs-Löschformular: jetzt in v1 umsetzen
# → /loeschen Route: ID eingeben → DELETE

17.3 Löschroutine (Cron)

# /opt/bsn-chatbot/purge.py (täglich via Cron):
# SELECT * FROM submissions WHERE deleted_at IS NOT NULL
#   AND deleted_at < date('now', '-30 days')
# → Medien von Platte löschen, DB-Eintrag hart löschen
# → DSGVO-Vermerk bleibt in separater Tabelle

18. Go-Live

  1. Ketchup cloudflared STOPPEN: systemctl stop cloudflared
  2. Dogado cloudflared STARTEN: systemctl start cloudflared
  3. DNS prüfen: CNAME chat.datenhimmel.work → Tunnel-ID (Cloudflare Dashboard)
  4. Warten: 2-5 Min DNS-Propagation
  5. Verifikation: curl https://chat.datenhimmel.work/health → 200
  6. 10-Punkte-Checkliste durchgehen
  7. 72h warten bevor alter Server endgültig abgeschaltet wird

19. Go-Live-Checkliste

#TestErwartet
1curl https://chat.datenhimmel.work/health200, "status":"ok"
2curl -sI https://chat.datenhimmel.work/200, cf-cache-status: HIT/MISS
3curl https://chat.datenhimmel.work/beitrag/1200, HTML
4curl "https://chat.datenhimmel.work/whatsapp/webhook?hub.mode=subscribe&hub.verify_token=br3ttsp1el&hub.challenge=test""test"
5Web-Submit POST200, "status":"queued"
6Nach ~30s: Admin prüfenSubmission mit Triage
7Admin lädt200, Submissions-Tabelle
8Cloudflare AccessOTP-Mail bei /admin
9Chat-Assistant200, JSON mit answer
10WhatsApp „Meine Daten löschen"Bestätigung, Daten gelöscht

20. Fallback

✅ v5.1-Fix: Gleiche Tunnel-Credentials auf beiden Servern. Rollback = systemctl start cloudflared auf Ketchup, systemctl stop cloudflared auf dogado. Kein DNS-Change nötig — Cloudflare routet zum aktiven Connector.

Rollback (innerhalb 72h)

# Auf Ketchup:
systemctl start cloudflared
# Auf dogado:
systemctl stop cloudflared
# Fertig. Cloudflare sieht den aktiven Connector auf Ketchup.

Nach 72 Stunden

# Auf Ketchup:
systemctl stop bsn-chatbot
rm ~/.cloudflared/<TUNNEL_ID>.json   # Credentials entfernen

21. Code-Änderungen

DateiÄnderungDauer
worker.py (NEU)Standalone Queue-Worker (Extrakt aus app.py + queue_worker.py)45 min
app.pyWebhook-Routen: Queue.enqueue() statt sync. LLM: OpenRouter. TTS raus. Kein Set-Cookie auf /.45 min
app.py/health entschlacken (3 Felder)5 min
purge.py (NEU)DSGVO-Löschroutine (Cronjob)30 min
app.py/loeschen Route (Selbstbedienung Web-ID)20 min
requirements.txtgunicorn hinzufügen, DeepSeek entfernen5 min

Gesamt: ~2,5 Stunden

22. Skalierung (1000/h)

PfadAnteilReq/hServerlast
Frontend (Cache HIT)85%8500% (Cloudflare)
Details (Cache MISS)8%80~1% CPU
Daten senden5%50~2% CPU
Chat-Assistant2%20~1% CPU + Netzwerk
Total1000~4% CPU

Server (12 GB, 6 Cores, NVMe) ist für 1000/h komfortabel überdimensioniert.

23. Zukunft

ErweiterungAuslöser
Lokales LLMOpenRouter-Kosten >100 €/Monat
TTS (Piper)WhatsApp-Sprachantworten gewünscht
SQLite → PostgreSQL>500 Submissions/h

24. Änderungen v5 → v5.1

Punktv5.0v5.1
Queue-WorkerDaemon-Thread in gunicorn __main__Eigener systemd-Service (worker.py)
nginx limit_req_zoneIm server-Kontext (geht nicht)Im http-Kontext
nginx Cacheproxy_cache_valid gesetztEntfernt (macht Cloudflare)
Webhook nginxlocation /webhooklocation ~ ^/(whatsapp|telegram)/webhook
Cloudflare CacheNur Strategie beschriebenExpliziter Installationsschritt + Set-Cookie-Prüfung
Admin-SchutzZukunftsfeatureCloudflare Access vor Go-Live
Tunnel-FailoverZwei getrennte TunnelGleiche Credentials, beide Server
Offsite-BackupNur lokalrsync zum Hermes-Server
Service-Userrootbsn
DSGVO-LöschroutineNicht in v1In v1: purge.py + Cron
/health8 Felder3 Felder (status, db, whatsapp)
Port-5002Mehrfach referenziertNur noch :8000 (gunicorn) + :80 (nginx)

Ende des Plans v5.1
Cody (Hermes Agent) + externes Review — 14.07.2026
Status: ✅ Umsetzungsreif