Conversationele workflows: slot filling met LLM-extractie

Een medewerker stuurt via WhatsApp: "zijn er nog diensten dit weekend?" Het systeem antwoordt met een overzicht. De medewerker reageert: "doe mij maar die zaterdag". Twee berichten later is hij aangemeld voor de dienst, zonder dat hij ooit een formulier heeft gezien.
Dat is wat slot filling in een conversationele context moet doen: gestructureerde invoer verzamelen — datum, keuze, bevestiging — vanuit ongestructureerde chat. De uitdaging is dat mensen niet praten zoals formulieren zijn opgebouwd. Ze zijn vaag, ze veranderen van gedachten, ze typen halve antwoorden.
Hier is hoe we dat hebben opgelost.
Wat is een slot?
Een slot is één stuk informatie dat een workflow nodig heeft. Een aanmeldingsflow voor een dienst heeft bijvoorbeeld drie slots: welke dienst, op welke datum, en een bevestiging. Elk slot heeft een instructie voor het LLM, een type (vrije tekst of een vaste keuze uit een lijst), en een volgorde.
De workflow-runner verwerkt berichten één voor één. Bij elk inkomend bericht bekijkt hij welk slot nog wacht op invulling en vraagt het LLM om de slotwaarde uit de conversatiehistorie te extraheren.
De extractie-aanroep
Het hart van het systeem is extractSlotWithLLM. Die functie stuurt de volledige conversatiegeschiedenis (max 8 berichten) naar het LLM met een strakke system prompt:
const systemPrompt = `You are a friendly assistant helping collect information
from a user via WhatsApp chat.
Slot instruction: ${slotInstruction}
Slot type: ${slot.slot_type}
Available options: ${availableOptions.join(", ")}
Already collected: ${collectedSummary}
Respond ONLY with valid JSON:
{"satisfied": boolean, "extractedValue": string | null, "reply": string}`;
Het LLM retourneert altijd JSON: satisfied (heeft de gebruiker dit slot ingevuld?), extractedValue (de geëxtraheerde waarde), en reply (het volgende bericht om te sturen). Met response_format: { type: 'json_object' } dwingen we structured output af — geen parsing-roulette.
De temperatuur staat op 0.3: laag genoeg voor consistente extractie, hoog genoeg om de antwoorden niet robotachtig te laten klinken.
De anti-hallucinatie gate
Als een slot keuze-opties heeft (bijvoorbeeld een lijst van diensten), voegt het LLM soms een optie "in" die niet bestaat. Dat vangen we op met een expliciete check na de extractie:
if (parsed.satisfied && availableOptions.length > 0) {
const lower = parsed.extractedValue.toLowerCase();
const matched = availableOptions.find(
(o) =>
o.toLowerCase() === lower ||
o.toLowerCase().includes(lower) ||
lower.includes(o.toLowerCase()),
);
if (!matched) {
return { satisfied: false, extractedValue: null, reply: parsed.reply };
}
return { satisfied: true, extractedValue: matched, reply: parsed.reply };
}
Als de geëxtraheerde waarde niet (fuzzy) overeenkomt met een beschikbare optie, forceren we satisfied: false — ook al zegt het model van wel. De extractedValue wordt genormaliseerd naar de exacte optietekst, zodat je niet "zaterdag" opslaat terwijl de database "Zaterdag 14 juni – vroege dienst" verwacht.
Retries en escalatie
Een gebruiker kan een vraag ontwijken, een verkeerd antwoord geven, of gewoon geen zin hebben. Na drie mislukte extractiepogingen escaleren we naar een beheerder:
const MAX_SLOT_RETRIES = 3;
if (retries >= MAX_SLOT_RETRIES) {
await escalateToAdmin(run, workflow, conversationId, orgId);
await workflowsServerRepo.updateRun(run.id, { status: "cancelled" });
return "Ik kan je helaas niet verder helpen. Ik heb een beheerder ingeseind die je verder kan helpen.";
}
De escalatie stuurt een notificatie naar alle beheerders van de organisatie, met een deeplink naar het gesprek. Zo valt er niks tussen wal en schip: wat het systeem niet kan oplossen, gaat naar een mens.
Annulering herkennen
Mensen die halfway een workflow willen stoppen, typen niet "cancel". Ze typen "nee", "stop", "doe maar niet". We checken een lijst Nederlandse cancel-keywords vóór elke extractiepoging:
const CANCEL_KEYWORDS = [
"stop",
"annuleer",
"cancel",
"stoppen",
"afbreken",
"nee",
];
const lower = inboundText.trim().toLowerCase();
if (CANCEL_KEYWORDS.some((kw) => lower.includes(kw))) {
return cancelWorkflowRun(run.id);
}
Substring-matching werkt hier goed — "nee bedankt" en "doe maar nee" worden allebei herkend. De trade-off is dat een bericht als "nee, ik bedoel de andere datum" ook cancelt. In de praktijk is dat geen probleem: de gebruiker kan gewoon een nieuw gesprek starten.
De opening: reageer op de vraag, forceer geen intent
Een subtiele maar belangrijke ontwerpkeuze: als een workflow getriggerd wordt, veronderstellen we niet dat de gebruiker wil aanmelden. We antwoorden op wat hij daadwerkelijk vraagt.
systemPrompt: `
IMPORTANT: If the user asked a question (e.g. "zijn er nog diensten?"),
ANSWER the question first — list the available options — then invite them
to pick one. Do NOT assume they want to sign up without them expressing that intent.`
Als iemand vraagt "zijn er nog plekken?", krijgt hij een lijst te zien. Als hij dan "doe mij maar optie 2" stuurt, start de eigenlijke registratie. Het systeem stelt de opening dynamisch op via het LLM, zodat de toon aansluit op de bewoording van de gebruiker.
Slots doorlopen
Als een slot tevreden is gesteld, zoekt de runner het volgende slot op positie:
const nextSlot = workflow.slots
.filter((s) => s.position > currentSlot.position)
.sort((a, b) => a.position - b.position)
.at(0);
Voor elk slot dat de runner naar de gebruiker wil vragen, genereert het LLM een korte, natuurlijke vraag op basis van de slot-instructie. Geen hard-gecodeerde strings — de formulering past zich aan de context aan.
Afsluiting: actie uitvoeren en bevestigen
Als alle slots zijn ingevuld, vuurt de runner de workflow-actie: medewerker aanmelden, entiteit registreren, notificatie versturen. De bevestigingsmelding gebruikt slot-interpolatie:
const confirmation = interpolate(
workflow.confirmation_message || "Je registratie is bevestigd! ✅",
slotValues,
);
// workflow.confirmation_message = "Je bent aangemeld voor {{dienst_naam}} op {{datum}}."
// → "Je bent aangemeld voor Coldplay-crew op zaterdag 14 juni."
Als de actie een dubbele registratie detecteert, geeft het systeem een vriendelijke melding terug zonder de workflow als mislukt te markeren — dat is een verwachte toestand, geen fout.
Waarom niet gewoon een formulier?
Dat is de eerlijke vraag. Een formulier is simpeler te bouwen, te debuggen en te onderhouden dan een conversationele workflow. Voor processen waarbij de gebruiker actief betrokken is (een webapplicatie opent, inlogt, een scherm invult), is een formulier bijna altijd beter.
Maar voor kanalen waar de gebruiker al zit — WhatsApp, Teams, Slack — is het frictieverschil enorm. Medewerkers hoeven geen app te openen, geen login, geen context-switch. Het systeem komt naar hen toe, niet andersom. Voor use cases zoals dienst-aanmeldingen, verlofaanvragen of korte check-ins levert dat substantieel hogere conversie op dan een formulier-link in een push-notificatie.
De complexiteit van slot filling is de prijs voor die frictionloze ervaring. Of die prijs de moeite waard is, hangt af van je kanaal en je doelgroep. Vertel ons over je use case als je wilt uitzoeken of conversationele workflows passen bij wat je bouwt.