<!--
Netpage GmbH - Beispiel-Skill: DSGVO-konformer LLM-Vorfilter
Lokal pseudonymisieren -> externes LLM mit Platzhaltern -> lokal re-personalisieren.
Einsatzfertiger Claude-Code-Skill, frei als Vorlage nutzbar. Stand: 2026-06.
Mehr / Aufbau in Ihrer Organisation: https://www.netpage.de/kontakt/
-->

---
name: DSGVO-LLM-Vorfilter
description: Verwende diesen Skill, wenn ein Text mit personenbezogenen oder sensiblen Daten von einem externen/kommerziellen LLM (z. B. Le Chat, ChatGPT, Claude-API) verarbeitet werden soll, ohne dass die Klardaten das lokale System verlassen. Der Skill pseudonymisiert lokal, ruft das externe Modell mit Platzhaltern auf und setzt die echten Werte lokal wieder ein. Nicht nutzen, wenn der Text bereits frei von Personenbezug ist oder ohnehin ein lokales Modell rechnet.
version: 1.0.0
tags: [dsgvo, datenschutz, llm, pii, pseudonymisierung, souveraene-ki]
allowed_tools: [Read, Write, Edit, Bash]
---

# DSGVO-LLM-Vorfilter

Lokaler PII-Vorfilter für die Nutzung externer LLMs. Prinzip: **Sensible Daten verlassen nie das eigene System.** Etablierte Praxis ("Anonymize -> LLM -> Deanonymize" mit lokalem Mapping); Bausteine u. a. Microsoft Presidio und LLM Guard.

## Auftrag

Wenn ein Text mit Personendaten von einem externen Modell verarbeitet werden soll: erst lokal pseudonymisieren, dann nur die pseudonymisierte Fassung extern verarbeiten, dann lokal re-personalisieren. Das Mapping (Platzhalter <-> echter Wert) bleibt ausschliesslich lokal.

## Leitplanken (nicht verhandelbar)

- **Klardaten gehen nie nach extern.** Vor jedem externen Aufruf prüfen: enthält der Prompt noch echte Namen, Adressen, Aktenzeichen, IBANs, Gesundheitsdaten o. Ä.? Wenn ja: nicht senden, nachbessern.
- **Mapping bleibt lokal.** Die Zuordnung Platzhalter <-> Originalwert nur im Arbeitsspeicher oder in einer lokalen Datei halten, die das System nicht verlässt. Niemals ins Prompt, Log oder an einen externen Dienst.
- **Mapping nach Gebrauch löschen.** Nach der Re-Personalisierung das Mapping sicher verwerfen (Datei löschen). Es ist ein Schutzgut: Wer Mapping + pseudonymisierten Text hat, kann re-identifizieren.
- **Ehrlich bleiben.** Pseudonymisierung ist nicht Anonymisierung. Pseudonymisierte Daten bleiben DSGVO-personenbezogen (Art. 4 Nr. 5). Freitext kann im Einzelfall Rückschlüsse zulassen (Inferenz/Re-Identifikation durch das Modell). Bei hoher Sensibilität ggf. nur lokales Modell nutzen.
- **Konsistenz.** Gleicher Originalwert -> gleicher Platzhalter (innerhalb eines Vorgangs), damit das Modell Bezüge versteht. Format/Typ andeuten (PERSON_1, ORT_2, IBAN_1), damit die Antwort sinnvoll bleibt.

## Ablauf

1. **Erkennen & Pseudonymisieren (lokal).** PII im Eingabetext finden und durch typisierte Platzhalter ersetzen. Mapping aufbauen. Empfohlen: Microsoft Presidio (NER + Regex + Kontext) oder ein lokales LLM (z. B. via Ollama) für kontextbewusste Erkennung in deutschem Freitext. Minimal-Fallback: Regex für strukturierte Werte (IBAN, E-Mail, Telefon, Steuer-/Aktenzeichen) + Namens-/Ortslisten.
2. **Prüfen.** Pseudonymisierten Text gegenlesen: Steht noch ein Klarwert drin? Erst weiter, wenn sauber.
3. **Extern verarbeiten.** Nur den pseudonymisierten Prompt an das externe Modell senden. Das Modell arbeitet mit Platzhaltern.
4. **Re-Personalisieren (lokal).** In der Modell-Antwort die Platzhalter aus dem Mapping wieder durch die Originalwerte ersetzen. Fertige Antwort entsteht erst lokal.
5. **Aufräumen.** Mapping löschen.

## PII-Kategorien (mindestens abdecken)

Namen (Personen), Adressen/Orte, Geburtsdaten, Telefon, E-Mail, IBAN/Kontodaten, Steuer-ID/Sozialversicherung, Aktenzeichen/Fallnummern, Kennzeichen, IP-Adressen, Gesundheits-/Diagnosedaten, Arbeitgeber/Organisation (sofern identifizierend). Branchenspezifisch ergänzen.

## Empfohlene Umsetzung A: Microsoft Presidio (robust, etabliert)

```bash
pip install presidio-analyzer presidio-anonymizer
python -m spacy download de_core_news_lg
```

```python
# pseudonymize.py  (laeuft komplett lokal)
from presidio_analyzer import AnalyzerEngine
from presidio_analyzer.nlp_engine import NlpEngineProvider
from presidio_anonymizer import AnonymizerEngine
from presidio_anonymizer.entities import OperatorConfig
import json, itertools

nlp_conf = {"nlp_engine_name": "spacy",
            "models": [{"lang_code": "de", "model_name": "de_core_news_lg"}]}
analyzer = AnalyzerEngine(nlp_engine=NlpEngineProvider(nlp_configuration=nlp_conf).create_engine(),
                          supported_languages=["de"])
anonymizer = AnonymizerEngine()

def pseudonymize(text, lang="de"):
    results = analyzer.analyze(text=text, language=lang)
    counters, mapping = {}, {}
    # stabile, typisierte Platzhalter + Mapping aufbauen
    for r in sorted(results, key=lambda r: r.start):
        original = text[r.start:r.end]
        if original in mapping.values():   # Konsistenz: gleicher Wert -> gleicher Platzhalter
            continue
        n = counters.get(r.entity_type, 0) + 1
        counters[r.entity_type] = n
        mapping[f"<{r.entity_type}_{n}>"] = original
    # ersetzen ueber Anonymizer (entity_type -> jeweiliger Platzhalter)
    # einfache Variante: direkte String-Ersetzung anhand des Mappings
    pseudo = text
    for ph, val in sorted(mapping.items(), key=lambda kv: -len(kv[1])):
        pseudo = pseudo.replace(val, ph)
    return pseudo, mapping

def repersonalize(answer, mapping):
    for ph, val in mapping.items():
        answer = answer.replace(ph, val)
    return answer

if __name__ == "__main__":
    text = open("input.txt", encoding="utf-8").read()
    pseudo, mapping = pseudonymize(text)
    open("pseudo.txt", "w", encoding="utf-8").write(pseudo)
    open("mapping.local.json", "w", encoding="utf-8").write(json.dumps(mapping, ensure_ascii=False))
    print("Pseudonymisiert. Pruefe pseudo.txt, sende NUR diese Datei extern.")
```

Nach der externen Antwort:

```python
import json
mapping = json.load(open("mapping.local.json", encoding="utf-8"))
answer = open("antwort_extern.txt", encoding="utf-8").read()
final = repersonalize(answer, mapping)
open("antwort_final.txt", "w", encoding="utf-8").write(final)
import os; os.remove("mapping.local.json")   # Schutzgut loeschen
```

## Empfohlene Umsetzung B: LLM Guard (Gateway mit Vault)

```bash
pip install llm-guard
```
LLM Guard hält das Mapping in einem `Vault`; `Anonymize`-Scanner vor dem Call, `Deanonymize`-Scanner auf die Antwort. Eignet sich, wenn ein wiederkehrender Gateway-Workflow gebaut wird.

## Empfohlene Umsetzung C: lokales LLM (Ollama) als kontextbewusster Vorfilter

Für deutschen Freitext mit Kontext (z. B. "der Patient aus dem Schreiben vom Montag") ist ein lokales LLM oft treffsicherer als reine NER. Mit **Ollama** läuft das komplett lokal auf eigener Hardware; das Mapping bleibt lokal.

```bash
# einmalig: Ollama installieren (ollama.com) und ein Instruct-Modell holen
ollama pull llama3.1        # oder qwen2.5, mistral-nemo, ... je nach Hardware/Deutsch-Güte
```

```python
# ollama_vorfilter.py  (laeuft komplett lokal, KEIN Cloud-Call)
import json, requests

SYS = ("Du bist ein Datenschutz-Vorfilter. Ersetze in der EINGABE alle personenbezogenen "
       "und sensiblen Angaben (Namen, Adressen, Geburtsdaten, Telefon, E-Mail, IBAN, "
       "Aktenzeichen, Kennzeichen, Gesundheitsdaten) durch typisierte Platzhalter wie "
       "<PERSON_1>, <ORT_1>, <IBAN_1>. Gleicher Originalwert -> gleicher Platzhalter. "
       "Veraendere sonst NICHTS am Text. Antworte AUSSCHLIESSLICH als JSON: "
       '{"pseudo": "<text mit platzhaltern>", "mapping": {"<PERSON_1>": "<original>"}}.')

def ollama_pseudonymize(text, model="llama3.1", host="http://localhost:11434"):
    r = requests.post(f"{host}/api/chat", json={
        "model": model,
        "format": "json",          # erzwingt valides JSON
        "stream": False,
        "options": {"temperature": 0},
        "messages": [{"role": "system", "content": SYS},
                     {"role": "user", "content": text}],
    }, timeout=300)
    r.raise_for_status()
    data = json.loads(r.json()["message"]["content"])
    return data["pseudo"], data["mapping"]

if __name__ == "__main__":
    text = open("input.txt", encoding="utf-8").read()
    pseudo, mapping = ollama_pseudonymize(text)
    # PFLICHT-Check: kein Originalwert darf im pseudonymisierten Text uebrig sein
    leaks = [v for v in mapping.values() if v in pseudo]
    assert not leaks, f"PII-Leak, nicht senden: {leaks}"
    open("pseudo.txt", "w", encoding="utf-8").write(pseudo)
    open("mapping.local.json", "w", encoding="utf-8").write(json.dumps(mapping, ensure_ascii=False))
    print("Lokal pseudonymisiert. Sende NUR pseudo.txt extern.")
```

Schnelltest ohne Python:

```bash
curl -s http://localhost:11434/api/chat -d '{
  "model":"llama3.1","stream":false,"format":"json",
  "messages":[
    {"role":"system","content":"Ersetze personenbezogene Daten durch Platzhalter <PERSON_1> usw. und gib JSON {pseudo, mapping} zurueck."},
    {"role":"user","content":"Herr Mueller (IBAN DE12 3456) wohnt in Wiesbaden."}]}' | python3 -c "import sys,json;print(json.loads(json.load(sys.stdin)['message']['content']))"
```

> [!tip] Das LLM-Erkennen kann Werte übersehen. Robust: Ollama-Vorfilter **plus** Presidio/Regex (Umsetzung A) kombinieren und beide Mappings mergen.

Wichtig: Für besonders sensible Daten (Art. 9 DSGVO) kann Ollama auch die **ganze** Aufgabe lokal erledigen — dann entfällt der Cloud-Schritt ganz und es gibt nichts zu pseudonymisieren.

## Verifikation vor dem externen Aufruf

- Mapping-Werte gegen den pseudonymisierten Text prüfen: kein Originalwert mehr enthalten.
- Stichprobe auf indirekte Identifikatoren (seltene Kombinationen, Eigennamen in Zitaten, ungewöhnliche Orte/Daten).
- Bei Restzweifel: Sensibilität hochstufen -> nur lokales Modell.

## Grenzen (ehrlich kommunizieren)

- Pseudonymisierung senkt das Risiko deutlich, beseitigt es nicht. Kontext kann re-identifizieren.
- Die Qualität steht und fällt mit der Entity-Erkennung; deutschsprachige, domänenspezifische Begriffe testen.
- Für besonders schützenswerte Daten (Art. 9 DSGVO) im Zweifel ganz lokal verarbeiten.
- Auftragsverarbeitung/Rechtsgrundlage des externen Anbieters bleibt zu klären; der Vorfilter ist eine technische Schutzmassnahme (Privacy by Design, Art. 25), kein Ersatz für die rechtliche Bewertung.
