Zum Inhalt springen

This page is available in English. Switch to English

Arbeit, die jede Woche wiederkommt? Muss kein Mensch machen. Mehr zu KI und Automatisierung

Automatisierung

KI-Zusammenfassungen aus CRM-E-Mails mit n8n und Mistral: Prompt, Parser und das Komma

Teil 5 von 6 der Serie Die Follow-up-Maschine

Viele feine, aufgefächerte Metallfäden links, zusammengeführt zu einem glatten polierten Band nach rechts.

Das ist Teil 5 der Serie zur Follow-up-Maschine. Teil 4 zeigt, wie die Maschine entscheidet, dass ein Nachfassen fällig ist. Dieser Teil behandelt „Followup 4: Enrich“ und damit die nächste Frage: Worum soll es beim Nachfassen gehen?

Der Workflow liest die letzten drei E-Mails zu jedem Deal und macht daraus zwei Zeilen: wie der aktuelle Stand des Deals ist, und welcher Aufhänger sich für das nächste Nachfassen anbietet. Diese Zeilen stehen in der ClickUp-Aufgabe und in der Morgenliste.

Er war nicht geplant. Er kam am zweiten Tag dazu, und die meisten seiner Fehler zeigten sich erst, als echter E-Mail-Text durchlief.

Was er tut und was nicht

  • Er fasst zusammen. Er schreibt keine Nachfass-E-Mail und verschickt nichts.
  • Er liest nur E-Mails, keine Anrufe und Termine.
  • Er läuft zur halben Stunde, 7:30 bis 19:30 Uhr werktags. Evaluate läuft zur vollen Stunde, also liegt frischer Kontext in der Datenbank, bevor die nächste Aufgabe entsteht.
  • Er ist nicht an Dry-Run gebunden. Der Modellaufruf liest nur aus dem CRM, er verschickt nichts nach außen. Er geht aber trotzdem an die API von Mistral und kostet dort Geld, und die Zusammenfassung wird in jedem Fall nach Postgres geschrieben (siehe Teil 2).

Nur zusammenfassen, was neu ist

Jede Stunde für jeden offenen Deal ein Modell aufzurufen wäre langsam, würde Geld kosten und bei unveränderten Daten nichts Neues liefern. Deshalb speichert jede Beobachtung den Zeitstempel der zuletzt verarbeiteten Aktivität, enriched_activity_at, also eine High-Water-Mark: den Zeitpunkt der neuesten Aktivität, die schon zusammengefasst ist.

Neue Aktivität? Zusammenfassen. Sonst überspringen.D5 / FOLLOW-UP MACHINENeue Aktivität? Zusammenfassen. Sonst überspringen.enriched_activity_at = Zeitstempel der zuletzt verarbeiteten Aktivität. Watchful Loop: Enrich EIN DEAL / DREI E-MAILSE-Mail 1E-Mail 2E-Mail 3enriched_activity_atvor Lauf A: E-Mails 1 + 2 berücksichtigtnach Erfolg aktualisierenLAUF A / neue Aktivitätemail 3 > enriched_activity_atZeitlich neuer → Modell aufrufenDie letzten E-Mails zusammenfassen.Zusammenfassung speichern; Zeitstempel auf E-Mail 3 setzen.LAUF B / nichts Neuesemail 3 = enriched_activity_atGleicher Zeitpunkt → überspringenKeine neue Zusammenfassung. Kein Modellaufruf.Der Zeitstempel bleibt bei E-Mail 3.Geparste Zeitstempel als Zeitpunkte vergleichen, nicht als formatierte Zeichenfolgen.
Lauf A findet eine neuere Aktivität und aktualisiert enriched_activity_at nach erfolgreicher Zusammenfassung. Lauf B findet keinen neueren Zeitpunkt und ruft das Modell nicht auf.

Für jede offene Beobachtung lädt der Workflow nur die neueste Aktivität:

GET {ESPO_BASE_URL}/api/v1/Activities/Opportunity/{id}/history?maxSize=1&orderBy=dateStart&order=desc

und ein IF-Node entscheidet, ob es etwas Neues gibt:

{{ !!$json.latest_activity_at
   && (!$json.enriched_activity_at
       || Date.parse($json.latest_activity_at) > Date.parse($json.enriched_activity_at)) }}

Die erste Version verglich die beiden Zeitstempel als Text. Postgres gibt timestamptz mit Millisekunden und eigenem Offset-Format aus, also lieferte der Textvergleich die falsche Antwort. Beide Zeitstempel werden zunächst geparst und anschließend als Zahlen verglichen.

Ein Set-Node, der an echten Daten scheiterte

Zwischen der Abfrage und diesem IF-Node sitzt ein Set-Node, „Map head“, der das Ergebnis für die weitere Verarbeitung aufbereitet. Er ging mit zwei Feldern ohne expliziten Datentyp in Betrieb. Der Set-Node in Version 3.4 ruft .toLowerCase() auf dem Typ auf. Fehlt die Typangabe, entsteht deshalb folgender Fehler:

Cannot read properties of undefined (reading 'toLowerCase')

Das passierte nur, wenn das Feld einen echten Wert hatte. Deshalb überstand der Fehler jeden Testlauf, bei dem der Deal keine Aktivität hatte, und schlug am 5. August zu, im ersten Lauf mit einem geführten Anruf. Jede Zuweisung in einem Set-Node bekommt seitdem einen expliziten Datentyp.

Derselbe Node nutzt für „kein Wert“ einen leeren Text, nicht null. '' ist falsy, also überspringt der IF-Node korrekt. Ein null, das unterwegs zum Text 'null' wird, ist truthy und hätte jede erste Zusammenfassung ohne Fehlermeldung verhindert.

Welche E-Mails zählen

GET {ESPO_BASE_URL}/api/v1/Email
  ?where[0][type]=equals&where[0][attribute]=parentId&where[0][value]={id}
  &where[1][type]=equals&where[1][attribute]=parentType&where[1][value]=Opportunity
  &orderBy=dateSent&order=desc&maxSize=3
  &select=bodyPlain,dateSent,from,name
  (dazu ein Filter auf den Status, siehe unten)

Nach dem ersten echten Lauf mussten sich zwei Dinge ändern:

  1. Entwürfe sind keine Korrespondenz. EspoCRM lässt dateSent bei Entwürfen leer, und der Prompt-Baustein sortierte danach. Jeder Entwurf an einem Deal ließ den Node abstürzen. Die Abfrage nimmt jetzt nur E-Mails mit dem Status Sent, Archived oder Received, und die Sortierung verkraftet ein leeres Datum trotzdem. Es ist dieselbe Überlegung wie beim geplanten Anruf, der nicht als Kontakt zählt.
  2. Der Zugriff auf eine E-Mail hängt davon ab, wer an ihr beteiligt war. EspoCRM zeigt eine E-Mail standardmäßig nur ihren Teilnehmern. Der API-Benutzer braucht in seiner Rolle Email: Read = All, sonst kann er keine Korrespondenz lesen, an der er nicht beteiligt war.

Der Prompt

Der Prompt entsteht in TypeScript und wird außerhalb von n8n getestet:

export const MAX_BODY_CHARS = 3000;
export const MAX_LINE_CHARS = 160;

export function buildEnrichmentPrompt(deal: EnrichDeal, emails: EnrichEmail[]): string {
  const ordered = [...emails].sort((a, b) => (b.dateSent ?? '').localeCompare(a.dateSent ?? ''));
  const blocks = ordered.map((e) => {
    const body = e.bodyPlain.length > MAX_BODY_CHARS ? `${e.bodyPlain.slice(0, MAX_BODY_CHARS)}…` : e.bodyPlain;
    return [`Date: ${e.dateSent ?? 'unknown'}`, `From: ${e.from ?? 'unknown'}`, `Subject: ${e.subject ?? '(none)'}`, body].join('\n');
  });
  return [
    `You are helping a solo consultant follow up on the sales deal "${deal.name}"${deal.stage ? ` (stage: ${deal.stage})` : ''}.`,
    'Below is the most recent email correspondence, newest first.',
    '',
    blocks.join('\n\n---\n\n'),
    '',
    'Respond with ONLY a JSON object, no prose, no code fences:',
    `{"context": "<one line, max ${MAX_LINE_CHARS} chars: where the deal stands right now>",` +
      ` "suggested_next": "<one line, max ${MAX_LINE_CHARS} chars: concrete angle for the next follow-up>"}`,
    'Write both lines in the same language as the correspondence.',
  ].join('\n');
}

Der Prompt ist auf Englisch formuliert, die Antwort nicht zwingend. Drei Entscheidungen stecken darin:

  • Jeder E-Mail-Text wird nach 3.000 Zeichen abgeschnitten. Zitierte Antwortketten machen E-Mails lang, und das Neueste steht oben.
  • Das Ausgabeformat ist JSON mit zwei festen Schlüsseln, kein freier Text.
  • „In der Sprache der Korrespondenz.“ Deals laufen auf Deutsch oder Englisch. Eine deutsche Zusammenfassung eines englischen Verlaufs wäre beim Lesen eine Übersetzung mehr.

Bei JSON lassen sich die erwarteten Felder und die Struktur prüfen. Ob der Inhalt stimmt, prüft das nicht, und bei Prosa lässt sich nicht einmal die Struktur prüfen.

Der Modell-Node

In n8n ist das ein Basic LLM Chain-Node mit angehängtem Mistral Cloud Chat Model, Modell mistral-small-latest.

Eine Falle: n8n hat bei den Basis-Nodes auch einen Node namens Mistral AI. Der ist für OCR, nicht für Chat, und leicht versehentlich gewählt.

Mistral Small reicht. Die Aufgabe ist, drei E-Mails auf zwei Zeilen zu verdichten, nicht, über sie nachzudenken.

Der Parser übernimmt nichts ungeprüft

function oneLine(value: unknown): string | null {
  if (typeof value !== 'string') return null;
  const collapsed = value.replace(/\s*\n\s*/g, ' ').trim();
  if (collapsed.length === 0) return null;
  return collapsed.length > MAX_LINE_CHARS ? collapsed.slice(0, MAX_LINE_CHARS) : collapsed;
}

export function parseEnrichmentResponse(raw: string): Enrichment {
  const stripped = raw.trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '');
  let parsed: unknown;
  try {
    parsed = JSON.parse(stripped);
  } catch {
    throw new Error(`enrichment parse failed: not JSON: ${stripped.slice(0, 120)}`);
  }
  const obj = parsed as Record<string, unknown>;
  const context = oneLine(obj['context']);
  const suggestedNext = oneLine(obj['suggested_next']);
  if (!context || !suggestedNext) {
    throw new Error('enrichment parse failed: context/suggested_next missing or empty');
  }
  return { context, suggestedNext };
}

Der Prompt verlangt „no code fences“. Modelle setzen sie manchmal trotzdem, also entfernt der Parser sie. Zeilenumbrüche innerhalb eines Werts werden zusammengezogen, und jede Zeile wird auf höchstens 160 Zeichen begrenzt, denn eine Aufgabenbeschreibung ist kein Ort für einen Absatz.

Ist die Antwort danach immer noch unbrauchbar, wirft der Parser einen Fehler. Der Code-Node leitet ihn auf seinen Fehlerausgang, der Deal wird übersprungen und der Lauf als partial markiert. Antworten, die sich nicht parsen lassen oder die erwartete Struktur nicht erfüllen, werden nicht gespeichert. Der Zeitstempel der zuletzt verarbeiteten Aktivität wird ebenfalls nicht aktualisiert, also kommt der Deal in der nächsten Stunde wieder dran.

Das Komma

Die Zusammenfassung geht nach Postgres. Und schon die erste echte Zusammenfassung hat den Schreibvorgang scheitern lassen.

Teil 3 beschreibt, dass der Postgres-Node von n8n seine Abfrageparameter als einen kommagetrennten Text aus Ausdrücken bekommt. Was Teil 3 nicht behandelt: Der Node zerlegt diesen Text erst nachdem er die Ausdrücke aufgelöst hat. Ein Komma innerhalb eines Werts zerlegt also den Wert.

Die erste echte Antwort von Mistral war ein deutscher Satz mit zwei Kommas. Daraus wurden drei Parameter. Alle folgenden Parameter verrutschten, und watch_id::bigint bekam einen halben deutschen Satz.

Kommas zwischen einer öffnenden und der passenden schließenden geschweiften Klammer überstehen das Zerlegen. Die Lösung übergibt deshalb ein einziges JSON-Objekt als einzigen Parameter und entpackt es in SQL:

update watches
set context              = $1::jsonb->>'context',
    suggested_next       = $1::jsonb->>'suggested_next',
    enriched_activity_at = ($1::jsonb->>'enriched_activity_at')::timestamptz,
    updated_at           = now()
where id = ($1::jsonb->>'watch_id')::bigint
returning id;

Dieselbe Lösung kam danach an zwei weitere Stellen, an denen der Fehler bis dahin noch nicht aufgetreten war: in den Alert-Workflow, der Fehlermeldungen speichert, und in die Registrierung, die Deal-Namen speichert. Ein Deal namens „Müller, GmbH“ hätte seine eigene Registrierung beschädigt.

In den Testdaten kam kein Komma vor. Das war der einzige Grund, warum beides noch nicht gescheitert war.

Freitext aus einem Modell oder von einem Menschen gehört nie als eigener Wert in diese Parameterkette.

Datenschutz, kurz

Dieser Workflow schickt den Klartext von bis zu drei E-Mails pro Deal an die API von Mistral. In diesen E-Mails steht, was die Gegenseite geschrieben hat, und die hat den Modellanbieter nicht ausgesucht. Wer das baut, für sich oder für Kunden, sollte vor dem ersten echten Lauf prüfen, was der Anbieter mit API-Eingaben macht und welche Regelungen der Auftragsverarbeitungsvertrag dafür enthält.

Was man aus diesem Teil mitnehmen kann

  • Den Zeitstempel der zuletzt verarbeiteten Aktivität speichern und das Modell nur bei neuer Aktivität aufrufen.
  • Zeitstempel als Zeitpunkte vergleichen, nicht als Text.
  • Jeder Zuweisung im Set-Node einen expliziten Datentyp geben.
  • JSON verlangen, streng parsen, und nie speichern, was sich nicht parsen lässt.
  • Freitext nie als eigenen Parameter im Postgres-Node übergeben. Ein JSON-Objekt übergeben.
  • Mit Daten testen, die wie echte Daten aussehen: Kommas, Entwürfe, leere Datumsfelder.

Weiter: Teil 6, Alerts und Reports.