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:

  1. Bestell-/Rechnungsnummern (7070049) werden mitgelesen. Niemand will sich um 7 Uhr morgens eine siebenstellige Artikelnummer anhören.
  2. Ein Titel ist nur eine nackte Nummer mit Doppelpunkt (7070049:) — kompletter Müll fürs Ohr.
  3. 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:

  1. 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.
  2. 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 None und 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=0 und 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)?