Zurück zu Technology

Wie unser Kategorisierer aus einer einzigen Korrektur lernt

Jede Transaktion auf Norman bekommt eine Kategorie, und die meisten davon berühren das Modell nie. Das Interessante ist nicht das LLM: Es ist die Kunst, aus einer einzigen manuellen Korrektur ein firmen-eigenes Gedächtnis zu machen, das niemals durchsickert, sich Vertrauen verdienen muss und beweisbar hängen bleibt. So haben wir es gebaut.

Kategorie
Allgemein
Aktualisiert
Autor:in
Stan Kharlap

Jede Banktransaktion auf Norman braucht eine Kategorie, bevor sie zu Buchhaltung wird. Das ist kein Nice-to-have: Die Kategorie entscheidet über die steuerliche Behandlung, darüber, ob Vorsteuer gezogen werden kann, in welche Zeile der Umsatzsteuervoranmeldung der Betrag fällt. Ist sie falsch, ist die Anmeldung falsch. In der Größenordnung, in der wir das betreiben, rund eine Million bislang kategorisierte Transaktionen und ein sechsstelliger Zulauf an neuen pro Monat, sortiert die niemand von Hand. Also muss es die Software tun.

Der naheliegende Weg, das 2026 zu bauen, ist: ein LLM auf die Beschreibung ansetzen und fragen. Das funktioniert, und es ist zugleich der uninteressanteste Teil. Ein Modell, das „Kartenzahlung STEAM PURCHASE Berlin" liest und Software rät, ist die Grundvoraussetzung. Das eigentliche Problem liegt darunter: Wenn ein Nutzer die Maschine überstimmt und von Hand eine andere Kategorie wählt, ist diese Korrektur das beste Trainingssignal, das wir für dieses Unternehmen je bekommen. Das ganze Design dreht sich darum, es einzufangen und niemals zu verschwenden.

Der günstigste Kategorisierer ist der, den du nie aufrufst

Ein LLM-Aufruf ist die langsamste, teuerste, am wenigsten vorhersehbare Option, die wir haben, also ist er das Letzte, wonach wir greifen, nicht das Erste. Kategorisierung ist eine Kaskade, und jede Stufe läuft nur, wenn die darüber leer ausging:

def categorize(self) -> CategorizationResult:
    result = self.categorize_by_accounting_rules()
    if result.category or result.company_category:
        return result

    result = self.categorize_by_company_pattern()
    if result.category or result.company_category:
        return result

    return self.categorize_by_ai()

Zuerst die deterministische Accounting-Rule-Engine: weit über tausend Regeln, kuratiert und pro Firma, die auf Gegenpartei, IBAN, Vorzeichen des Betrags und Ähnliches matchen. Feuert eine Regel, sind wir fertig, kein Modell, keine Latenz. Dann das Firmen-Gedächtnis, um das es in diesem Beitrag geht. Erst wenn beide vorbeigehen, geben wir einen LLM-Aufruf aus. Neun von zehn Transaktionen in Produktion tragen bereits eine Kategorie, und die, über die das Modell von Grund auf nachdenken muss, sind eine schrumpfende Minderheit. Jede Korrektur eines Nutzers schiebt eine weitere Gegenpartei aus dem „das Modell fragen"-Eimer in den „das wissen wir schon"-Eimer, dauerhaft.

Kategorisierung als dreistufige Kaskade und Lernschleife. Die Kaskade probiert zuerst Accounting-Regeln, dann ein firmen-eigenes Gedächtnis namens CompanyPattern, dann ein LLM, und geht nur bei einem Fehlschlag nach rechts. Darunter lässt eine manuelle Korrektur das Gedächtnis um eine Einheit Evidenz wachsen und erzeugt einen redigierten Eval-Fall; ein nächtlicher Replay lässt die gesamte Kaskade erneut laufen, um zu prüfen, dass die Korrektur nicht wiederkehrt.
Die Kaskade probiert die günstigste Stufe zuerst. Deine Korrekturen füttern das Gedächtnis in der Mitte und erzeugen einen Eval-Fall; ein nächtlicher Replay beweist, dass die Korrektur hielt.

Eine Korrektur ist das stärkste Signal, das wir haben

Der Erfassungspunkt ist bewusst langweilig. Wenn ein PATCH die Kategorie einer Transaktion ändert, merkt der Updater, dass sich das Feld bewegt hat, greift die vorherigen Werte ab, bevor sie überschrieben werden, und plant die Lern-Arbeit für nach dem Datenbank-Commit:

def capture_categorization_correction(self) -> None:
    # Implicit feedback for the learning loop: the user hand-picking a
    # category is the strongest training signal we have. Captured before
    # the setattr pass (we need the previous values), dispatched after
    # commit so a failed PATCH never records anything.

Zwei Invarianten stecken in diesem Kommentar. Wir lesen die alte Kategorie vor dem Schreiben, denn die Korrektur ist die Differenz zwischen dem, was die Maschine sagte, und dem, was der Mensch wählte, und sobald die Zeile aktualisiert ist, ist das „Vorher" weg. Und wir versenden per on_commit, sodass ein PATCH, der aus irgendeinem Grund zurückgerollt wird, dem System nie eine Lektion beibringt, die gar nicht stattgefunden hat. Keine Korrektur wird erfunden, keine geht verloren.

Ein Gedächtnis, das sich Vertrauen verdienen muss

Das Gedächtnis selbst ist eine firmen-bezogene Tabelle, CompanyPattern. Der Docstring nennt die eine Regel, auf die es am meisten ankommt:

class CompanyPattern(TimestampedModel):
    """Company-scoped memory for agents.

    Grown from implicit feedback (user corrections) and consumed by the
    categorizer and the assistant: "for THIS company, this counterparty is
    Software", etc. Deliberately per-company: one company's corrections must
    never leak into another's suggestions.
    """

Der Schlüssel ist nicht die rohe Bankbeschreibung, die voller Rauschen pro Zahlung steckt: Karten-IDs, Daten, Referenznummern. Wir entfernen die Ziffern und behalten die ersten paar stabilen Token, sodass Kartenzahlung STEAM PURCHASE 12345 Berlin und die Kartenzahlung STEAM PURCHASE 67890 Berlin vom nächsten Monat auf denselben Gegenpartei-Schlüssel normalisieren.

Entscheidend: Eine Korrektur reicht nicht, um automatisch zu handeln. Ein gemerktes Muster bucht eine Transaktion erst dann von allein, wenn es mindestens zweimal bestätigt wurde:

# A pattern must be confirmed at least twice before it applies deterministically.
CONFIRMED_EVIDENCE_THRESHOLD = 2

Und wenn eine spätere Korrektur mit dem im Widerspruch steht, was wir uns gemerkt haben, gewinnt die neueste menschliche Entscheidung, aber sie muss von vorn anfangen, Vertrauen zu verdienen:

if pattern.value == value:
    pattern.evidence_count += 1
else:
    pattern.metadata = {**pattern.metadata, "previousValue": pattern.value}
    pattern.value = value
    pattern.evidence_count = 1

Eine wiederholte identische Korrektur ist eine Bestätigung: Die Evidenz wächst, und sobald sie die Schwelle überschreitet, bucht die Gegenpartei sofort ganz ohne Modellaufruf. Eine widersprechende Korrektur setzt die Evidenz auf eins zurück und legt den alten Wert in den Metadaten ab. Die neueste Entscheidung wird immer respektiert, aber sie darf das Modell nie überspringen, bis das Unternehmen mehr als einmal gezeigt hat, dass es das wirklich so meint.

Dasselbe Gedächtnis, zweifach genutzt

Das ist der Teil, den ich mag: Genau dieselbe Tabelle bedient zwei sehr verschiedene Konsumenten.

Unter der Schwelle, oder wenn die Richtung des Cashflows nicht passt, wird dem Muster nicht zugetraut, allein zu entscheiden. Aber es ist trotzdem der beste Hinweis, den wir haben, also reist es als Few-Shot-Block in den LLM-Prompt und beschreibt, wie dieses Unternehmen seine wiederkehrenden Gegenparteien bucht:

def company_pattern_examples(company, *, cashflow_type=""):
    """Few-shot block for the categorization prompt: how THIS company books
    its recurring counterparties. Names only, never ids."""

Die Ausgabe liest sich wie 'steam purchase' -> Software; 'aws' -> IT services. Nur Namen, nie Datenbank-IDs, denn das Modell hat mit unseren Primärschlüsseln nichts zu tun, und wir haben nichts damit zu tun, ihm zuzutrauen, sie korrekt zurückzugeben. Über der Schwelle kürzt dasselbe Muster das Ganze ab und bucht deterministisch. Ein Gedächtnis, zwei Geschwindigkeiten: Ein sicherer Treffer überspringt das Modell komplett, ein unsicherer lenkt das Modell in Richtung der eigenen Historie dieser Firma.

Das Gedächtnis einer Firma ist nie das einer anderen

Die Isolation pro Firma ist kein Kommentar, sie ist ein Uniqueness-Constraint auf (company, kind, key) und ein company=-Filter bei jedem Lesen. Ein Freelancer, der Steam als Betriebsausgabe bucht, und einer, der das nie täte, sind zwei getrennte Gedächtnisse, die einander nie sehen.

Eine weitere Schutzregel verdient sich hier ihren Platz. Freelancer-Buchhaltung in Deutschland vergibt nur Blatt-Kategorien, nie eine oberste Elternkategorie. Selbst ein gemerktes, zweifach bestätigtes Muster wird also abgelehnt, wenn es auf eine Elternkategorie zeigt:

# Freelancer bookkeeping assigns only child categories: a remembered
# top-level parent must never be applied. SME companies use their own
# flat chart-of-accounts set and are exempt.
if category and category.parent_id is None and not company.is_sme:
    category = None

Das Gedächtnis darf in Richtung „noch mal fragen" falsch liegen, nie in Richtung „etwas buchen, mit dem die nachgelagerte Steuerlogik nicht umgehen kann".

Beweisen, dass eine Korrektur wirklich hängen blieb

Ein Gedächtnis wachsen zu lassen ist leicht. Zu beweisen, dass eine Korrektur den Fehler am Wiederkehren gehindert hat, ist der schwere, langweilige, wertvolle Teil. Wenn ein Nutzer eine Kategorie überstimmt, die die Maschine bereits gesetzt hatte, aktualisieren wir nicht nur das Gedächtnis; wir prägen aus der Korrektur einen Eval-Fall, verschlüsselt über einen Fingerprint des Fehlers. Ein nächtlicher Job spielt diese Fälle durch die aktuelle Pipeline und prüft, ob die Maschine jetzt das produziert, was der Mensch gewählt hat:

def categorization_replay_worker(input_payload):
    """Replay the eval case's source transactions through the CURRENT
    categorization pipeline (rules -> company patterns -> LLM) and report
    whether it now produces the user-corrected values.

    Eval cases are redacted (hashes, no raw values), so the replay resolves
    the source transactions by public id and compares stable hashes of the
    computed correction shape against the recorded ones.
    """

Zwei Details machen das sicher genug, um es für immer zu behalten. Die Eval-Fälle sind redigiert: Sie speichern Hashes der korrigierten Form, nicht rohe Kategorien oder Beschreibungen, sodass die Regressions-Suite keinerlei Kundeninhalte trägt. Und die Prüfung ist ein Vergleich stabiler Hashes, computed_hash == recorded_hash, was pro Befund ein klares shouldNotRecur-Boolean liefert statt eines unscharfen Scores, den jemand deuten muss. Eine Korrektur, die zurückfällt, leuchtet am nächsten Morgen auf.

Prompts sind Daten, kein Deploy

Der Kategorisierungs-Prompt ist kein String, der in einen Release eingefroren ist. Er ist eine Zeile:

def get_active_prompt(workflow: str) -> tuple[str, str]:
    """Prompt-as-data: an empty body means "use the hardcoded default": the
    code is always a safe fallback, so activating/deactivating rows in the
    admin can never break the pipeline."""

Ein leeres Override bedeutet „nimm den im Code eingebackenen Default", also ist das Schlimmste, was eine schlechte Prompt-Zeile anrichten kann, auf die Version zurückzufallen, die wir ausgeliefert haben. Der Prompt kann sich ohne Deploy ändern, jeder Lauf hält fest, welche Prompt-Version ihn erzeugt hat, und der nächtliche Replay sagt uns, ob die Änderung tatsächlich geholfen hat, bevor sich irgendjemand darauf festlegt.

Die langweiligen Teile sind das Produkt

Es gibt eine schillerndere Version dieser Funktion: eine Chat-Box, in der du einem Assistenten sagst „buche alles von Steam als Software", und er tut es. Die haben wir nicht gebaut, denn der Wert liegt nicht im Moment der Anweisung, er liegt darin, die Anweisung nie ein zweites Mal geben zu müssen. Also stützt sich das Design ganz auf die unglamouröse Mechanik. Eine Kaskade, die das Modell meidet, wann immer sie kann. Ein Gedächtnis, das bestätigt werden muss, bevor ihm vertraut wird, und das sich Vertrauen neu verdient, wenn es falsch liegt. Strikte Isolation pro Firma, erzwungen durch ein Constraint, nicht durch ein Versprechen. Und eine Eval-Schleife, die am nächsten Morgen beweisen kann, dass deine Korrektur wirklich griff. Automatisiere die mühsame Arbeit vollständig, halte jede Schutzregel explizit, und sei in der Lage zu zeigen, dass das System genau das gelernt hat, was du ihm beigebracht hast, und nichts, was du nicht wolltest.

Norman übernimmt die operative Arbeit im Hintergrund

Von Rechnungen bis Buchhaltung: Norman organisiert wiederkehrende Finanzarbeit, damit du Fristen sauber einhältst und weniger manuell nachhalten musst.