Das Problem
Mein Home Assistant liest mir morgens einen Tagesbericht über die Küchen-Lautsprecher vor: Termine, Müllabholung — und neue Support-Tickets aus dem Ticketsystem der letzten 24 Stunden. Praktisch. Bis die Sprachausgabe anfängt, sowas vorzulesen:
„Zammad: 4 neue Tickets in den letzten 24 Stunden: Anfrage Lieferbarkeit: Lichtschwert-Kristall 16 Millimeter, 7070049: : Ihr neuer digitaler Imperium-Versicherungsnachweis, Ihre Imperium Rechnung, 7070049:."
Drei Probleme auf einmal:
- Bestell-/Rechnungsnummern (
7070049) werden mitgelesen. Niemand will sich um 7 Uhr morgens eine siebenstellige Artikelnummer anhören. - Ein Titel ist nur eine nackte Nummer mit Doppelpunkt (
7070049:) — kompletter Müll fürs Ohr. - Führende Doppelpunkte (
: Ihr neuer...) machen die Ansage holprig.
Die Ticket-Titel kommen halt so aus dem System, wie Menschen (und Mailbetreffzeilen) sie eben erzeugen. Eine Sprachausgabe braucht aber knappe, natürliche Sätze.
TL;DR
Titel-Bereinigung in zwei Schichten:
- Regel-Basis (Regex, deterministisch): entfernt lange Ziffernblöcke, glättet Satzzeichen, wirft reine Nummern-Titel raus. Läuft immer, fällt nie aus, erfindet nie etwas.
- LLM-Veredelung (DeepSeek, optional): formuliert die bereinigten Titel knapp und natürlich.
Der Clou ist der harte Fallback: Fehlt der API-Key, läuft der Call in einen Timeout, kommt Müll zurück oder ändert sich die Anzahl der Titel — dann wird einfach die Regel-Version vorgelesen. Die Ansage bleibt niemals stumm und liest niemals Halluzinationen vor.
Ergebnis:
„Zammad: 3 neue Tickets in den letzten 24 Stunden: Lichtschwert-Kristall 16 Millimeter Lieferbarkeit, Digitaler Imperium-Versicherungsnachweis, Imperium Rechnung."
Die Architektur-Entscheidung
Die naheliegende Idee ist: „Wirf die Titel ins LLM und lass es aufräumen." Funktioniert — bis es nicht funktioniert. Das Skript läuft autonom (per Cron bzw. Automation), niemand sitzt davor. Wenn der LLM-Call hängt, ist die Frühstücks-Ansage weg. Wenn das LLM kreativ wird, erzählt dir dein Lautsprecher Tickets, die es nie gab.
Die Regel lautet deshalb: In einem autonomen Skript ist das LLM nie der einzige Pfad. Es ist die Kür obendrauf, nie das Fundament. Das Fundament muss eine dumme, deterministische Funktion sein, die garantiert ein brauchbares Ergebnis liefert.
Wenn du LLM-Logik in irgendein unbeaufsichtigtes Skript baust und keinen Fallback hast — dann mag ich deinen Humor. Du wirst ihn brauchen, wenn das Modell mal nicht antwortet.
Schicht 1: Die ausfallsichere Regel-Basis
Reines Python, keine externen Abhängigkeiten. Sie macht drei Dinge: lange Zahlen weg, Satzzeichen glätten, Müll-Titel verwerfen. Wichtig: kurze Zahlen (Maße wie „16 mm", Mengen) bleiben erhalten — eine Bestellnummer hat ≥5 Stellen, ein Maß selten.
import re
def clean_ticket_title(title: str):
"""Regelbasierte Bereinigung eines Ticket-Titels für die TTS-Ansage.
Liefert None, wenn nach der Bereinigung kein sinnvoller Inhalt übrig bleibt."""
if not title:
return None
s = re.sub(r"\b\d{5,}\b", "", title) # lange Nummern raus
s = re.sub(r"\s*([:;,])\s*", r"\1 ", s) # Satzzeichen normalisieren
s = re.sub(r"\s*[:;,]\s*(?=[:;,])", "", s) # aufeinanderfolgende entdoppeln
s = re.sub(r"\s+", " ", s).strip() # Whitespace glätten
s = s.strip(" :;,") # führende/hängende Satzzeichen weg
return s or None
Damit wird aus "7070049:" → "" → None (fliegt raus), und aus ": Ihr neuer ... Nachweis" → "Ihr neuer ... Nachweis". Das allein ist schon 80 % der Miete — und das ohne jedes Netzwerk.
Schicht 2: Die optionale LLM-Veredelung
Erst jetzt kommt DeepSeek ins Spiel, und nur mit angezogener Handbremse: temperature=0, JSON-Mode für robustes Parsing, und ein Prompt, der ausdrücklich verbietet, etwas zu erfinden.
import json, urllib.request
DEEPSEEK_API = "https://api.deepseek.com/chat/completions"
def summarize_tickets_llm(titles: list, secrets) -> list:
"""Verdichtet bereinigte Titel zu kurzen, natürlichen Begriffen.
Liefert None bei fehlendem Key, Fehler, Timeout oder unplausibler Antwort."""
key = secrets.get("deepseek_api_key")
if not key or not titles:
return None
sys_prompt = (
"Du bist ein Textbereiniger für eine gesprochene Tagesansage. Du bekommst eine "
"JSON-Liste von Ticket-Titeln und gibst sie als JSON {\"items\": [...]} zurück. "
"Regeln: (1) GENAU EINEN bereinigten Eintrag pro Eingabe-Titel, gleiche Reihenfolge. "
"(2) Knapp und natürlich formulieren. (3) Irrelevante Nummern entfernen. "
"(4) Maßangaben (z.B. '16 mm') erhalten. (5) Erfinde KEINE neuen Informationen."
)
body = json.dumps({
"model": "deepseek-chat",
"temperature": 0,
"response_format": {"type": "json_object"},
"messages": [
{"role": "system", "content": sys_prompt},
{"role": "user", "content": json.dumps(titles, ensure_ascii=False)},
],
}).encode("utf-8")
req = urllib.request.Request(DEEPSEEK_API, data=body, method="POST",
headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=15) as r:
data = json.loads(r.read())
items = json.loads(data["choices"][0]["message"]["content"]).get("items")
# Halluzinations-Schutz: gleiche Anzahl, alle nicht-leer
if isinstance(items, list) and len(items) == len(titles) \
and all(isinstance(x, str) and x.strip() for x in items):
return [x.strip() for x in items]
return None
except Exception:
return None
Der entscheidende Teil ist nicht der API-Call — den kann jeder. Es ist der Struktur-Check nach der Antwort:
if isinstance(items, list) and len(items) == len(titles) \
and all(isinstance(x, str) and x.strip() for x in items):
Du vertraust dem LLM nicht, du prüfst es. Liefert es 2 Einträge für 3 Tickets, hat es vermutlich zwei zusammengefasst oder eins erfunden — beides nicht das, was du willst. Anzahl-Mismatch → komplett verwerfen, Regel-Version nutzen. Diese eine len()-Prüfung ist dein billigster Halluzinations-Schutz.
Beides verdrahten — mit eingebautem Fallback
Die zwei Schichten kommen in einer Funktion zusammen. Trick für die Testbarkeit: Die LLM-Schicht wird als Callback injiziert, nicht fest verdrahtet. So testet man die ganze Logik ohne Netzwerk.
def format_tickets(tickets, now_utc, refine=None):
recent = _recent_clean_titles(tickets, now_utc) # Schicht 1, immer
if not recent:
return "Zammad: keine neuen Tickets in den letzten 24 Stunden."
if refine is not None:
try:
refined = refine(recent) # Schicht 2, optional
if refined and len(refined) == len(recent) \
and all((x or "").strip() for x in refined):
recent = [x.strip() for x in refined]
except Exception:
pass # jeder Fehler -> ausfallsichere Regel-Version
n = len(recent)
return (f"Zammad: {n} neue{'s' if n == 1 else ''} Ticket"
f"{'s' if n != 1 else ''} in den letzten 24 Stunden: "
+ ", ".join(recent) + ".")
Im Produktivbetrieb wird der Callback eingehängt:
tickets = format_tickets(fetch_tickets(secrets), now_utc,
refine=lambda t: summarize_tickets_llm(t, secrets))
Im Test übergibt man einfach eine Fake-Funktion — kein Mock-Framework, kein Monkeypatching:
def test_format_tickets_uses_refine_when_plausible():
out = format_tickets(tickets, now, refine=lambda t: ["Lieferanfrage Kristall"])
assert "Lieferanfrage Kristall" in out
def test_format_tickets_falls_back_when_refine_returns_none():
out = format_tickets(tickets, now, refine=lambda t: None)
assert "Imperium Rechnung" in out # Regel-Version
def test_format_tickets_falls_back_on_count_mismatch():
# refine liefert 1 statt 2 -> unplausibel -> Regel-Fallback
out = format_tickets(two_tickets, now, refine=lambda t: ["nur eins"])
assert "nur eins" not in out
Die refine-Injektion ist der Grund, warum man alle Fallback-Pfade deterministisch abdecken kann, ohne je api.deepseek.com zu kontaktieren.
Lessons Learned
- LLM ist die Kür, nicht das Fundament. In autonomen Skripten muss die deterministische Schicht allein ein brauchbares Ergebnis liefern. Das LLM verbessert nur — und darf jederzeit ausfallen.
- Vertrauen ist kein Fehler-Handling. Prüfe die LLM-Antwort strukturell (Anzahl, Typ, nicht-leer). Ein Anzahl-Mismatch ist der billigste und wirksamste Halluzinations-Detektor.
- Fallback heißt: jeder Fehlerpfad führt zur Regel-Version. Fehlender Key, Timeout, kaputtes JSON, Exception — alles landet im selben
except/return Noneund damit bei der ausfallsicheren Basis. - Callback-Injektion macht LLM-Code testbar. Reiche die LLM-Funktion als Parameter rein. Dann deckst du Erfolg, Fallback und Halluzination mit normalen Unit-Tests ab — ohne Netzwerk.
temperature=0+ JSON-Mode für alles, was maschinell weiterverarbeitet wird. Du willst Determinismus und robustes Parsing, keine Kreativität.
Checkliste für LLM-Calls in autonomen Skripten
- Gibt es eine deterministische Basis-Schicht, die ohne LLM funktioniert?
- Führt jeder Fehlerpfad (Key/Timeout/Parse/Exception) zur Basis-Schicht zurück?
- Wird die LLM-Antwort strukturell validiert (Anzahl, Typ, nicht-leer)?
- Ist ein Timeout gesetzt (kein unendliches Blockieren)?
-
temperature=0und ein „erfinde-nichts"-Prompt? - Ist die LLM-Schicht injizierbar, damit Fallback-Pfade testbar sind?
- Liegt der API-Key außerhalb des Codes (Secrets-Datei, nicht im Repo)?