Testdaten, weil echte nicht gehen

Testdaten, weil echte nicht gehen

Artikel 3 · Serie: Einstieg in n8n

Das Demo-Projekt klassifiziert Support-Tickets. Dafür braucht man Tickets — am besten realistische, in ausreichender Menge, mit bekannter Kategorienverteilung. Echte Tickets aus einem produktiven System scheiden für ein öffentliches Repo aus: sie enthalten Personendaten, Systemkonfigurationen, Buchungsnummern, manchmal sogar Passwörter im Freitext. Das ist kein Datenschutz-Disclaimer als Pflichtübung, sondern ein echtes Inhaltsproblem. Der Datensatz zu diesem Artikel liegt auf Codeberg, Tag v0.3: codeberg.org/rotecodefraktion/n8n-einstieg.

Die Optionen im Vergleich

Vier Kandidaten gibt es, wenn man keine echten Daten verwenden kann:

OptionProblem
Öffentliche Customer-Support-DatasetsFast ausschließlich B2C-Telco-Daten auf Englisch — kein SAP-Kontext, keine DACH-Sprache
GitHub Issues als QuelleRechtlich grau (Nutzungsrechte der Poster), falsche Domäne, bereits vorklassifiziert
Rein LLM-generiert ohne StrukturPrompt „Erzeuge 500 Support-Tickets" produziert 500 Variationen desselben Tickets
Hybrid: LLM mit Kategorien und PersonasStreuung kommt aus der Struktur, nicht aus dem Zufall

Die vierte Option ist die, die wir nehmen. Der Schlüssel liegt in der Unterscheidung zwischen Streuung und Zufall. Ein LLM, das ohne Kontext Tickets generiert, reproduziert seinen eigenen Bias. Persona und Kategorie als explizite Eingaben erzwingen Vielfalt — Tonalität, Fachbereich, Dringlichkeit sind kontrollierte Parameter, keine Hoffnung.

Kategorien × Personas

Der Generator arbeitet mit sechs Kategorien und fünf Personas. Jede Kombination erzeugt ein anderes Ticket.

Kategorien:

IDThemaSchlüsselbegriffe
sap-basisSystembetrieb, Transport, PatchesSM50, STMS, Kernel-Update, ABAP-Dump
sap-functionalFI, MM, SD, HR, ProzesseBuchungsbeleg, Bestellung, Lieferung, Abrechnung
infrastrukturServer, Netzwerk, Backup, StorageRAID, NFS, Latenz, Sicherung fehlgeschlagen
cloudAzure, Kubernetes, TerraformPod-Crash, Subscription-Limit, State-Drift
security-pkiZertifikate, Berechtigungen, CVEZertifikat abgelaufen, SU53, Penetrationstest
sonstigesAlles Übrige

Personas:

IDProfilTonalität
end-user-frustratedGenervter EndanwenderKurze Sätze, keine Fachbegriffe, Ausrufezeichen
key-user-urgentHektischer Key-UserViel Kontext, SAP-Transaktionen, Zeitdruck
admin-precisePräziser AdminFehlercodes, ruhiger Ton, vollständige Details
manager-vagueVager ManagerWenig Details, hohe Dringlichkeit
external-reporterExterner MelderFormell, unvollständige Information

Drei Beispiel-Tickets aus dem Datensatz, um das Zusammenspiel zu zeigen:

sap-basis / de / end-user-frustrated:

„SAP-Login nach Passwortreset nicht möglich. Nach dem Reset durch die IT komme ich nicht mehr ins System. Fehlermeldung: SM50 meldet Verbindungsausfall. Das blockiert meine Arbeit komplett!"

sap-basis / de / admin-precise:

„Transportauftrag schlägt fehl in STMS. Transport DEVK900123 lässt sich nicht importieren. Fehler in SM37: Job RDDIMPDP terminiert mit Dump. Patch-Level-Unterschied zwischen DEV und QAS könnte Ursache sein."

sap-basis / en / admin-precise:

„Kernel update causing SM50 work process restarts. After patch level upgrade to 7.93, work processes restart every 30 minutes. RFC connections drop simultaneously."

Dieselbe Kategorie, drei verschiedene Tickets. Die Persona bestimmt, wie jemand schreibt, nicht was das Problem ist.

Lokales Modell für die Masse

Etwa 85 Prozent der Tickets entstehen mit einem lokalen Modell via Ollama. Das spart API-Kosten, schafft keine Cloud-Abhängigkeit und läuft auf Apple Silicon mit Qwen 2.5 7B mit rund 30 Tickets pro Minute.

Der Adapter in llm_local.py schickt Kategorie, Persona-System-Prompt und eine Auswahl an Schlüsselbegriffen an die Ollama REST API:

def is_hard_case(category: str, persona: str) -> bool:
    hard_categories = {"security-pki"}
    hard_personas = {"external-reporter"}
    return category in hard_categories or persona in hard_personas

Wer kein Ollama installiert hat, kann den Generator mit --no-local starten. Claude übernimmt dann alle Fälle, oder man nutzt nur den gepinnten Datensatz ohne Neugeneration.

Für Apple-Silicon-Nutzer ist MLX eine Alternative zu Ollama. llm_local.py enthält dafür einen kommentierten Abschnitt mit mlx_lm.generate() — umschalten genügt, der Rest des Generators bleibt unverändert.

Hummingbird-Gateway als einheitliches Backend

Das Hummingbird-Gateway aus der gleichnamigen Reihe auf rotecodefraktion.de ist ein in Swift geschriebener LLM-Proxy, der auf Apple Silicon läuft und sowohl das Anthropic-Format (/v1/messages) als auch das OpenAI-Format (/v1/chat/completions) exponiert. Wer das Gateway betreibt, kann den gesamten Generator ohne direkte Ollama- oder Anthropic-API-Calls betreiben.

llm_local.py auf das Gateway umstellen:

Statt http://localhost:11434/api/generate (Ollama native API) das OpenAI-kompatible Endpoint des Gateways verwenden:

GATEWAY_BASE = "http://localhost:8080"
DEFAULT_MODEL = "mlx-community/Qwen2.5-7B-Instruct-4bit"

# POST /v1/chat/completions statt /api/generate
payload = json.dumps({
    "model": DEFAULT_MODEL,
    "messages": [
        {"role": "system", "content": system},
        {"role": "user", "content": prompt}
    ],
    "temperature": 0.7,
    "seed": ticket_id
}).encode()
req = urllib.request.Request(
    f"{GATEWAY_BASE}/v1/chat/completions",
    data=payload,
    headers={"Content-Type": "application/json",
             "Authorization": "Bearer local"}
)

llm_claude.py auf das Gateway umstellen:

Das Anthropic Python SDK liest ANTHROPIC_BASE_URL aus der Umgebung. Zeigt die Variable auf das Gateway, laufen auch die „Claude"-Calls über das lokale Modell:

export ANTHROPIC_BASE_URL=http://localhost:8080
export ANTHROPIC_API_KEY=local   # beliebiger String, Gateway prüft eigenen Token

Das Gateway übernimmt dann die Formatübersetzung: es empfängt den Anthropic-Request, konvertiert ihn in das OpenAI-Format und schickt ihn an mlx_lm.server oder Ollama weiter.

Ergebnis: Ein lokaler Endpoint für alle LLM-Calls, keine Cloud-Abhängigkeit, Logging und Rate-Limiting im Gateway, kein API-Key für Anthropic nötig. Der Generator verhält sich von außen identisch.

Claude für die Härtefälle

Zwei Routing-Kriterien schicken ein Ticket zu Claude statt zum lokalen Modell:

security-pki: Zertifikats-Terminologie, CVE-Nummern, SU53-Fehlerbilder — hier produzieren 7B-Modelle häufig plausibel klingende, aber fachlich falsche Formulierungen. Claude liefert in dieser Kategorie sichtbar dichtere Texte.

external-reporter: Formelle Ambiguität und absichtlich unvollständige Informationen sind schwer für kleine Modelle zu imitieren. Das Ticket soll wirken wie von jemandem geschrieben, der nicht weiß, was ein SAP-System ist — aber trotzdem einen formellen Ton trifft.

Der llm_claude.py-Adapter fordert strukturierten JSON-Output an:

prompt = (
    f"Schreibe ein Support-Ticket (Betreff + 2-4 Sätze) über {cat['label_de']}. "
    f"Priorität: {priority}. Verwende: {', '.join(kw_sample)}. "
    f"Antworte als JSON: {{\"subject\": \"...\", \"body\": \"...\"}}"
)

Das macht den Claude-Output direkt parsebar, ohne auf Regex angewiesen zu sein. Etwa 15 Prozent der Tickets entstehen so. Bei 500 Tickets sind das rund 75 Claude-Calls, was bei claude-sonnet-4-6 unter einem Dollar liegt.

Validator als Qualitätsschicht

Nicht jedes generierte Ticket ist brauchbar. Der Validator in validator.py prüft:

  • Pflichtfelder vorhanden und nicht leer
  • Kategorie, Priorität, Sprache in erlaubten Wertebereichen
  • Subject mindestens 5 Zeichen, Body mindestens 10 Zeichen
  • Keine Generierungs-Artefakte im Subject: JSON-Reste, Prompt-Fragmente wie „Schreibe ein" oder „Antworte als"

Ein Ticket, das den Validator nicht passiert, wird verworfen und der Generator erzeugt einen Ersatz. In der Praxis trifft das weniger als drei Prozent der lokalen Outputs und unter einem Prozent der Claude-Outputs.

Tests mit Hypothesis

Die Tests in testdata/generator/tests/ setzen auf Property-based Testing mit Hypothesis. Das ist für Datengeneratoren natürlicher als Beispiel-basierte Tests, weil Hypothesis automatisch Grenzfälle sucht:

@given(st.fixed_dictionaries({
    "id": st.just("TKT-0001"),
    "subject": st.text(min_size=5, max_size=120),
    "body": st.text(min_size=10, max_size=1000),
    "category": st.sampled_from(sorted(VALID_CATEGORIES)),
    "priority": st.sampled_from(sorted(VALID_PRIORITIES)),
    "language": st.sampled_from(sorted(VALID_LANGUAGES)),
    "persona": st.just("admin-precise"),
    "generated_by": st.just("local"),
}))
def test_valid_ticket_passes(ticket):
    result = validate_ticket(ticket)
    assert result is not None

Dazu kommen Verteilungs-Sanity-Checks: keine Kategorie unter 5 Prozent im Datensatz, keine Persona über 30 Prozent. Der Idempotenz-Check prüft, dass gleicher Seed gleichen Output liefert.

Tests laufen mit uv run pytest testdata/generator/tests/.

Drei ADRs als Architektur-Gedächtnis

Drei Entscheidungen haben einen eigenen ADR bekommen:

ADR 001 — Synthetisch statt echt: Begründung für die Wahl synthetischer Daten, verworfene Alternativen (öffentliche Datasets, GitHub Issues). Im Repo unter docs/adr/001-testdatengenerierung.md.

ADR 002 — DE/EN im Verhältnis 60/40: Warum nicht rein deutsch. Mehrsprachigkeit ist kein Zusatz-Feature, sondern die Bedingung, unter der regelbasierte Klassifikation (Artikel 4) sichtbar scheitert. Im Repo unter docs/adr/002-mehrsprachigkeit.md.

ADR 003 — Hybrid-LLM, lokal plus Claude: Routing-Kriterien und ihre Begründung, Kosten-Abschätzung, verworfene Alternativen (rein lokal, rein Claude). Im Repo unter docs/adr/003-hybrid-llm-generierung.md.

ADRs sind kurz, jeder unter einer DIN-A4-Seite. Wer den Generator für eigene Zwecke anpasst, findet dort die Begründungen für die Entscheidungen, die nicht im Code stehen.

Generator aufsetzen und ausführen

Wer den Datensatz selbst generieren will, braucht vier Schritte.

1. Repo klonen und auf Tag v0.3 wechseln:

git clone https://codeberg.org/rotecodefraktion/n8n-einstieg.git
cd n8n-einstieg
git checkout v0.3

2. uv installieren und Abhängigkeiten holen:

# uv installieren (einmalig)
curl -LsSf https://astral.sh/uv/install.sh | sh

# Abhängigkeiten holen
cd testdata/generator
uv sync

3. Ollama starten und Modell laden:

ollama serve &
ollama pull qwen2.5:7b

Auf Apple Silicon alternativ mit mlx_lm.server — der kommentierte Abschnitt in llm_local.py beschreibt die Umstellung.

4. Generator ausführen:

uv run python -m generator --seed 42 --n 500

Das schreibt testdata/tickets.parquet und testdata/tickets.jsonl. Die Laufzeit liegt je nach Maschine zwischen 15 und 30 Minuten. Wer schneller fertig sein will: --n 100 reicht für erste Experimente mit dem Workflow in Artikel 4.

Wer keinen Anthropic-API-Key hat, fügt --no-claude an. Die ca. 15 Prozent der Tickets, die normalerweise zu Claude gehen, werden dann ebenfalls lokal generiert. Die Qualität in security-pki und external-reporter ist etwas flacher, aber für den Einstieg ausreichend.

Der gepinnte Datensatz

testdata/tickets.parquet enthält rund 500 Tickets, generiert mit Seed 42. Wer den Generator nicht selbst ausführen will oder keinen Anthropic-API-Key hat, kann direkt mit diesem Datensatz arbeiten — alle Folgeartikel setzen nur den Datensatz voraus, nicht den Generator. Parquet als Format: typisiert, kompakt, schnell zu laden mit pandas oder pyarrow. Da n8n kein Parquet lesen kann, gibt es die gleichen Daten als tickets.jsonl, eine Zeile pro Ticket.

Verteilung im gepinnten Datensatz:

KategorieAnteil
sap-functional~25%
sap-basis~20%
infrastruktur~20%
cloud~15%
security-pki~10%
sonstiges~10%

Sprache: ~60% Deutsch, ~40% Englisch. Priorität: ~30% low, ~40% medium, ~20% high, ~10% critical.

Der Datensatz ist unter CC0 veröffentlicht. Ähnlichkeiten mit echten Supportfällen sind zufällig. Wer eigene Experimente machen will, nimmt einen anderen Seed:

cd testdata/generator
uv run python -m generator --seed 99 --n 500 --output ../my-tickets.parquet

Der gepinnte Datensatz unter Seed 42 bleibt unverändert — alle Folgeartikel testen gegen denselben Stand.

Der nächste Artikel: der erste Workflow

Artikel 4 nimmt diesen Datensatz und baut den ersten n8n-Workflow: regelbasierte Ticket-Klassifikation mit Switch-Node, Set-Node und Code-Node. Kein KI-Modell, nur Keyword-Matching. Und genau dort wird sichtbar, warum 60/40 DE/EN im Datensatz die richtige Entscheidung war. Tag v0.4.

Artikel 2: Self-Hosting mit Docker Compose → Artikel 4: Nodes, Expressions und der erste Workflow (erscheint demnächst)