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:
| Option | Problem |
|---|---|
| Öffentliche Customer-Support-Datasets | Fast ausschließlich B2C-Telco-Daten auf Englisch — kein SAP-Kontext, keine DACH-Sprache |
| GitHub Issues als Quelle | Rechtlich grau (Nutzungsrechte der Poster), falsche Domäne, bereits vorklassifiziert |
| Rein LLM-generiert ohne Struktur | Prompt „Erzeuge 500 Support-Tickets" produziert 500 Variationen desselben Tickets |
| Hybrid: LLM mit Kategorien und Personas | Streuung 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:
| ID | Thema | Schlüsselbegriffe |
|---|---|---|
sap-basis | Systembetrieb, Transport, Patches | SM50, STMS, Kernel-Update, ABAP-Dump |
sap-functional | FI, MM, SD, HR, Prozesse | Buchungsbeleg, Bestellung, Lieferung, Abrechnung |
infrastruktur | Server, Netzwerk, Backup, Storage | RAID, NFS, Latenz, Sicherung fehlgeschlagen |
cloud | Azure, Kubernetes, Terraform | Pod-Crash, Subscription-Limit, State-Drift |
security-pki | Zertifikate, Berechtigungen, CVE | Zertifikat abgelaufen, SU53, Penetrationstest |
sonstiges | Alles Übrige | — |
Personas:
| ID | Profil | Tonalität |
|---|---|---|
end-user-frustrated | Genervter Endanwender | Kurze Sätze, keine Fachbegriffe, Ausrufezeichen |
key-user-urgent | Hektischer Key-User | Viel Kontext, SAP-Transaktionen, Zeitdruck |
admin-precise | Präziser Admin | Fehlercodes, ruhiger Ton, vollständige Details |
manager-vague | Vager Manager | Wenig Details, hohe Dringlichkeit |
external-reporter | Externer Melder | Formell, 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:
| Kategorie | Anteil |
|---|---|
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)