Ich baue einen Chatbot für Zimmerbuchungen.


Moin moin und hallo,

in diesem Artikel möchte ich einen aktuellen Trend, nämlich Chatbots, aufgreifen und mit Hilfe von “botmasterai” zeigen wie man einen simplen Bot aufbauen könnte, der Buchungsanfragen auf nimmt.

Motivation

Nach über 20 Jahren im Untergrund feiern Chatbots derzeit ein Revival, auch wenn noch vorallem in den USA. Ich möchte den Begriff “Chatbot” auch auf Alexa, Siri und Google Home erweitern, denn diese Technologien nutzen im Kern, die gleiche Idee, wobei die Kommunikation zum Bot nicht mehr schriftlich sondern per “natürlicher” Sprache erfolgt.

“natürlicher”, da es trotzdem bestimmte Schlüsselsätze und Begriffe gibt, die entweder das Gerät aktivieren oder eine spezifische Interaktion einleiten.

Ich möchte zeigen wie man einen einfachen Arbeitsfluss, in diesem Fall mit Hilfe eines Chatbots der per WebSocket angesteuert wird, implementieren kann.

Aber warum sollte man überhaupt Chatbots einsetzen und nicht bei den üblichen Formularen bleiben? Ich bin noch selbst unschlüssig warum es derzeit einen kleinen Hype erlebt, denn ich bin selbst an die HTML-Formular gewohnt.

Aber ich kann mir vorstellen, dass es von der Bedienung her (ob Text oder Sprache) für die Nutzer natürlicher ist Fragen und Eingaben direkt vor zu nehmen. Dadurch ist vermutlich die Conversion-Rate größer als bei einem klassischen Formular.

Der Arbeitsfluss, den ich implementieren möchte sieht wie folgt aus:

  1. Der Nutzer wird begrüßt
  2. Wenn der Nutzer einen Schlüsselsatz oder Wort eingibt wird der Buchungsprozess gestartet
  3. Der Buchugnsprozess fragt alle Kerndaten ab
  4. Der Buchungsprozess wird abgeschlossen und der Nutzer informiert

Lösungsansatz

Für die Lösung nutze nutze ich das botmasterai Framework, das mir die Möglichkeit gibt den Bot gleich in mehreren System (Twitter, WebSockets, Facebook, etc.) zu integrieren. Tatsächlich werde ich aber nur eine WebSocket Version zeigen.

Das Backend

Das Backend wird ein einfacher Express JS Dienst sein, der auch den Frontend Client hosten wird. Ich möchte zunächst die Projektstruktur zeigen, auf die Inhalte gehe ich in Folge ein:

Projektstruktur

Die Abhängigkeiten halten sich in Grenzen, die package.json:

{
    "name": "chatbot",
    "version": "1.0.0",
    "description": "",
    "main": "app.js",
    "scripts": {
        "test": "echo \"Error: no test specified\" && exit 1",
        "start": "node app.js"
    },
    "author": "Roman Fetsch",
    "license": "ISC",
    "dependencies": {
        "botmaster": "^3.2.0",
        "botmaster-fulfill": "^4.0.0",
        "botmaster-fulfill-actions": "^1.1.2",
        "botmaster-socket.io": "^1.0.3",
        "express": "^4.16.3",
        "fuse.js": "^3.2.0"
    }
}

Die app.js startet wie folgt:

const express = require('express');
const port = process.env.PORT || 3000;
const app = express();

// Client 
app.use(express.static(__dirname + '/public'));

const server = app.listen(port, '0.0.0.0', () => {
    console.log('Server listening at port %d', port);
});

Das ist aber nur die halbe Wahrheit denn es fehlt noch das Setup des Bots (weiterhin app.js):


const Botmaster = require('botmaster');
const SocketioBot = require('botmaster-socket.io');

//Helfer
const {FulfillWare} = require('botmaster-fulfill');
const actions = require('botmaster-fulfill-actions');

//Bot initialisieren
const botmaster = new Botmaster({
    server,
});

//WebSocket Bot-Setup
const socketioSettings = {
    id: 'BOTID123',
    server,
};

//Bot Anbindung initialisieren, in diesem Fall WebSocket
const socketioBot = new SocketioBot(socketioSettings);
botmaster.addBot(socketioBot);

//Behandlung (middleware) der eingehenden Nachrichten
const incomingMiddleware = require('./middleware/incoming');

botmaster.use(incomingMiddleware.reply.replyToUser);

//Helferfunktionen für Pausen und Ähnliches
botmaster.use(FulfillWare({
    actions
}));

botmaster.on('error', (bot, err) => { 
    console.log(err.stack); 
}); 

Das Setup ist relativ einfach, aber den Hauptteil der Arbeit leistet die Middleware. Wie man der Projektsturtur und dem Setup entnehmen kann, haben wir lediglich ein Antwortobjekt, dass auch eingehende Nachrichten reagiert. Der Vollständigkeit halber sei noch erwähnt, dass man auch auf rausgehende Nachrichten einwirken kann.

Schauen wir erstmal auf die middleware/incoming/index.js:

const reply = require('./reply');

module.exports = {
    reply
};

Dieses Modul dient als Aggregator für alle späteren Hooks, die auf eingehende Nachrichten reagieren müssen.

Die größte Komplexität steckt in der “middleware/incoming/reply.js”:

const flow = require('../../model/flow');
const Fuse = require('fuse.js');


const options = {
    keys: ['keywords', 'text']
};

const fuse = new Fuse(flow, options);

var context = {};

const resetToNewFlow = (step) => {
    context.currentStep = step;
    context.enteredSubflow = false;
    context.flowData = {};
    context.subflowState = 0;
};

const replyToUser = {
    type: 'incoming',
    name: 'reply-to-user',
    controller: (bot, update) => {

        var flowStep = fuse.search(update.message.text);
        //Neuer Flow?
        if (flowStep && flowStep.length > 0 && flowStep[0] != context.currentStep) {

            resetToNewFlow(flowStep[0]);

            return bot.reply(update, flowStep[0].text);

        }
        //Soll der Flow betreten werden?
        else if (context.currentStep && !context.enteredSubflow) {
            let usedCondition = context.currentStep.conditions[update.message.text];
            if (usedCondition && usedCondition == 'enterFlow') {
                context.enteredSubflow = true;
                context.subflowState = 0;
                context.flowData = {};

                let subFlow = context.currentStep.flow[context.subflowState];
                bot.reply(update, context.currentStep.states[subFlow].text);
            } else {
                bot.reply(update, context.currentStep.text);
            }

        }
        //Ist der Flow betreten worden?
        else if (context.currentStep && context.enteredSubflow) {


            let subFlow = context.currentStep.flow[context.subflowState];
            if (subFlow) {
                context.flowData[context.currentStep.states[subFlow].context] = update.message.text;
            }

            context.subflowState += 1;
            subFlow = context.currentStep.flow[context.subflowState];

            if (subFlow) {
                bot.reply(update, context.currentStep.states[subFlow].text);
            } else {
                let message = "Das sind Ihre Buchungsdetails:<pause/>";
                message += "Name: " + context.flowData.name + "<pause/>";
                message += "Anreise: " + context.flowData.from + "<pause/>";
                message += "Abreise: " + context.flowData.to + "<pause/>";
                message += "Anzahl Personen: " + context.flowData.persons + "<pause/>";
                message += "Sie brauchen einen Parkplatz: " + context.flowData.car + "<pause/>";
                message += context.currentStep.finalMessage;

                bot.reply(update, message);
                console.log("Final-Data: ", context.flowData);
                context.currentStep = null;
            }
        }
        //Ich weiß nicht was der Nutzer möchte, deshalb schreibe ich eine Default-Nachricht raus
        else {
            bot.reply(update, 'Hallo!<pause/>Wie kann ich Ihnen weiterhelfen?<pause />Möchten Sie sich über das Hotel informieren oder ein Zimmer buchen?');
        }
    }
};

module.exports = {
    replyToUser
}

Für die Erkennung von Schlüssel-Sätzen oder Worten nutze ich Fuse JS, das auch ähnlich aussehende Satzfragmente, trotz Tippfehler, erkennt. Um die Flow-Verarbeitung aber voll zu verstehen muss man sich das Datenmodell, dass ich mir dafür ausgedacht habe vor Augen führen.

Dieses Datenmodel kommt derzeit aus einer einfachen JSON-Datei, kann aber ohne Weiteres auch aus eienr Dabtenbank wie z.B. MongoDB geladen werden, model/flow.js:

const flow = [

    {
        "name": "booking",
        "keywords": ["buchung", "reservierung"],
        "flow": [
            "from",
            "to",
            "persons",
            "car",
            "name",
            "payment"
        ],
        "text": "Möchten Sie ein Zimmer buchen? <pause/>[Ja, Nein]",
        "finalMessage": "Vielen Dank für Ihre Buchung!",
        "conditions": {
            "Ja": "enterFlow"
        },

        "states": {
            "from": {
                "text": "Wann möchten Sie Anreisen?",
                "context": "from"
            },
            "to": {
                "text": "Wann möchten Sie Abreisen?",
                "context": "to"
            },
            "persons": {
                "text": "Wie viele Personen reisen an?",
                "context": "persons"
            },
            "car": {
                "text": "Reisen Sie mit dem Auto an?",
                "context": "car"
            },
            "name": {
                "text": "Auf welchen Namen soll gebucht werden?",
                "context": "name"
            },
            "payment": {
                "text": "Möchten Sie jetzt bezahlen?<pause/>[Ja, Nein]",
                "context": "payment",
                "conditions": {
                    "Ja": "enterPaymentFlow"
                }
            }
        }
    }
]


module.exports = flow;

Es gibt also zwei Arten von Flows:

  1. Übergeordnete Flows, wie z.B. “Buchung”
  2. Die selbst sich aus Sub-Flows zusammn setzt
    ..
    "flow": [
            "from",
            "to",
            ..
        ]

Jeder Schritt hat einen Text-Teil, den man dem Nutzer kommuniziert. Bei den übegeordneten Flows muss man zusätzlich eine Aktion auslösen um ind en Kontext ein zu steigen. Die Subflows fungieren als Abfragen für die Einzelwerte und können später genutzt werden um andere, übergeordnete Flows aus zu lösen oder externe System an zu steuern.

Wenn wir durch alle Buchungsschritte durch gegangen sind, wird noch einmal alle unseren Details aus gegeben.

Frontend

Auf das Frontend möchte ich gar nicht tief eingehen, da es nahezu trivial aussieht:

public/client.js


//UserId, die von botmasterei erkannt wird und wir serverseitig nutzen können
var socket = io('?botmasterUserId=wantedUserId');

// alle wichtigen elemente aus dem DOM laden
var form = document.getElementById('form');
var textInput = document.getElementById('text-input');
var messages = document.getElementById('messages');

form.onsubmit = function (event) {
    // wir senden das Formular nicht wirklich weg, sondern senden die Dtaen über das WebSocket
    event.preventDefault();

    if (!textInput.value) {
        return;
    }
    //Nachricht in der Oberfläche hinzufügen
    messages.insertAdjacentHTML('beforeend',
        `<li class="user-message">${textInput.value}</li>`);

    // nachrichten VO das dem botmaster format entspricht
    const update = {
        message: {
            text: textInput.value
        }
    };

    // sende per webSocket
    socket.send(update);

    // das Textfeld wird zurück gesetzt
    textInput.value = '';
};

//sobald eine Nachricht vom server ankommnt, fügen wir diese im frontend ein
socket.on('message', function (botmasterMessage) {
    var textMessage = botmasterMessage.message.text;

    messages.insertAdjacentHTML('beforeend',
        `<li class="botmaster-message">${textMessage}</li>`);
});

//damit der Server den Nutzer begrüßt, senden wir automatisch eine Grußnachricht
socket.on('connect', function () {
    const update = {
        message: {
            text: 'Hallo botmaster!'
        }
    };

    socket.send(update);
});

Das HTML dazu ist denkbar simpel, public/index.html:

<!doctype html>
<html>
  <head>
    <title>Botmaster bot</title>
    <link rel="stylesheet" type="text/css" href="client.css">
  </head>
  <body>
    <div class="chat">
      <ul id="messages"></ul>
      <form id="form" action="">
        <input type="text" id="text-input" autocomplete="off" /><button>Send</button>
      </form>
    </div>

    <script src="/socket.io/socket.io.js"></script>
    <script src="client.js"></script>
  </body>
</html>

Und schließlich die public/client.css:

* {
  margin: 0;
  padding: 0;
  box-sizing:
  border-box;
}

body {
  font: 13px Helvetica, Arial;
}

form {
  background: #000;
  padding: 3px;
  position: fixed;
  bottom: 0;
  width: 100%;
}

form input {
  border: 0;
  padding: 10px;
  width: 90%;
  margin-right: .5%;
}

form button {
  width: 9%; background: rgb(130, 224, 255);
  border: none; padding: 10px;
}

#messages {
  list-style-type: none;
  margin: 0;
  padding: 0;
}

#messages li {
  padding: 5px 10px;
}

#messages .botmaster-message {
  background: #eee;
}

Fazit

Wie sieht das nun im echten Betrieb aus?

Hier ein kleines Video:

Ich habe explizit ein einfaches Beispiel gewählt um einen Einstieg in die Thematik zu finden.


#### In diesem Sinne, bis demnächst!