Online Eiscreme Bestellungen mit Dialogflow und Firebase —
Teil 2: Firebase

1. Aufgabe

Im ersten Teil dieser Fallstudie haben wir erfolgreich bei der Eisdiele unserer Wahl eine Eiscreme-Bestellung mit Hilfe eines Spracherkennungssystems übermittelt. Zur weiteren Bearbeitung der Bestellung legen wir diese nun in einem cloud-basierten Backend ab. Bei Dialogflow bietet sich hier Firebase als Mobile-backend-as-a-service (MBaaS) – auch unter der Bezeichnung Backend-as-a-service (BaaS) bekannt – aus dem Google-Baukasten an.

Zeigen Sie eine Realisierung auf, wie sich die Bestellungen der Kunden in einer Firebase Realtime-Database-Datenbank ablegen lassen.

2. Lösung

Quellcode: Siehe auch github.com/peterloos/Dialogflow_OnlineIcecreamParlor.git.

Die Aktivierung eines Intents von Dialogflow hängt prinzipiell von der Erkennung der gesprochenen Benutzereingabe ab. Die Antwort kann ebenfalls rein sprachbasiert sein – die Sektion der Responses eines Intents ist dann entsprechend auszufüllen. Bedarf es neben einer sprachbasierten Antwort zusätzlicher Aktivitäten (Zugriffe auf ein Backend, Zugriffe auf ein Datenbank-System, Zugriffe auf Web- bzw. RESTful-Services etc.), sind wir beim Dialogflow-Feature des so genannten fulfillments angekommen. Hierbei handelt es sich um JavaScript-Code, der alternativ zu einer Response im Umfeld von Firebase Cloud Functions ausgeführt wird.

Wir betrachten eine fulfillment-Realisierung an Hand folgender Anforderungen:

  • Ablage einer Eiscreme-Bestellung im Realtime-Database Backend von Firebase.

  • Überprüfung der Bestellung auf etwaige Fehler bei der Bestellannahme (Anzahl Eiskugeln und Anzahl gewählter Geschmackssorten muss übereinstimmen).

  • Erzeugung einer eindeutigen Bestellnummer für jede Bestellung.

  • Übermittlung des Auftragseingangs – inkl. Bestellnummer – an den Auftraggeber.

Die Realisierung der aufgeführten Anforderungen finden Sie in Listing 1 vor:

001: 'use strict';
002: 
003: const functions = require('firebase-functions');
004: const admin = require('firebase-admin');
005: const { WebhookClient } = require('dialogflow-fulfillment');
006: 
007: // initialise DB connection
008: admin.initializeApp({
009:     credential: admin.credential.applicationDefault(),
010:     databaseURL: 'ws://petersicecreamparlor.firebaseio.com/',
011: });
012: 
013: process.env.DEBUG = 'dialogflow:debug';
014: 
015: exports.dialogflowFirebaseFulfillment = functions.https.onRequest((request, response) => {
016: 
017:     // firebase
018:     const refOrders = '/orders';
019: 
020:     const agent = new WebhookClient({ request, response });
021:     console.log('Dialogflow Request body: ' + JSON.stringify(request.body));
022: 
023:     function handlePlaceOrder(agent) {
024: 
025:         let context = agent.getContext('customer_order');
026:         console.log('==> Context ==> customer_order ==> ' + JSON.stringify(context));
027: 
028:         let container = context.parameters.container;
029:         let scoops = context.parameters.scoops;
030:         let flavors = context.parameters.flavors;
031: 
032:         if (scoops !== flavors.length) {
033: 
034:             agent.add('Sorry, dear customer: The number of scoops ' +
035:                 'and flavors does not match!');
036:             agent.add('Could you please start again and tell me the number ' +
037:                 'of scoops you want to order:');
038: 
039:             agent.setContext({
040:                 name: 'awaiting_scoops',
041:                 lifespan: 1,
042:                 parameters: {}
043:             });
044:             return;
045:         }
046: 
047:         // using object literal property value shorthand
048:         let order = {
049:             container,
050:             scoops,
051:             flavors,
052:             timeOfOrder: admin.database.ServerValue.TIMESTAMP
053:         };
054: 
055:         let database = admin.database();  // get a reference to the database service
056: 
057:         let key = '';
058:         return admin.database().ref('control').transaction(
059:             (currentData) => {
060:                 if (currentData !== null) {
061:                     let latestId = currentData.latestId;
062:                     latestId += 1;
063:                     currentData.latestId = latestId;
064: 
065:                     // add pickup name to order
066:                     order.pickupName = latestId;
067:                     console.log('==> Created unique pickup id ' + latestId);
068:                 }
069:                 return currentData;
070:             }, (error, isSuccess) => {
071:                 if (error) { console.log('Internal error: transaction failed !'); }
072:             }).then(() => {
073:                 return database.ref(refOrders).push();
074:             }).then((newRef) => {
075:                 key = newRef.key;
076:                 return newRef.set(order);
077:             }).then(() => {
078:                 let reply = 'Thank you for your order! Your pickup id is ';
079:                 agent.add(reply + order.pickupName);
080:                 agent.add('See you soon! Bye Bye!');
081:                 console.log('==> Added ' + key + ' to firebase list of orders!');
082:                 return key;
083:             })
084:             .catch((err) => {
085:                 let msg =
086:                     "FirebaseClassesModule: ERROR " + err.code +
087:                     ", Message: " + err.message;
088:                 console.log('==> Firebase push failed! ' + msg);
089:                 throw msg;
090:             });
091:     }
092: 
093:     function handleTakeScoops(agent) {
094: 
095:         // extract number of scoops from intent parameters
096:         let scoops = agent.parameters.scoops;
097:         if (scoops === 1) {
098:             agent.add('Okay, you want one scoop!');
099:         }
100:         else {
101:             agent.add('Okay, you want ' + scoops + ' scoops!');
102:         }
103: 
104:         agent.add('How about our delicious flavours. ' +
105:             'You can choose between strawberry, vanilla and chocolate flavor. ' +
106:             'Which flavours do you like?!');
107: 
108:         // using object literal property value shorthand
109:         let parameters = { scoops };
110: 
111:         agent.setContext({
112:             name: 'awaiting_flavors',
113:             lifespan: 1,
114:             parameters
115:         });
116:     }
117: 
118:     function handleTakeFlavors(agent) {
119: 
120:         // extract list of flavors from intent parameters
121:         let flavors = agent.parameters.flavors;
122: 
123:         // extract number of scoops from context 'awaiting_flavors' parameters
124:         let context = agent.getContext('awaiting_flavors');
125:         console.log('==> Context ==> awaiting_flavors ==>  ' + JSON.stringify(context));
126: 
127:         let scoops = context.parameters.scoops;
128:         if (scoops !== flavors.length) {
129: 
130:             agent.add('Sorry, dear customer: The number of scoops and flavors ' +
131:                 ' does not match!');
132:             agent.add('Could please start again and tell me ' +
133:                 'the number of scoops you want');
134: 
135:             agent.setContext({
136:                 name: 'awaiting_scoops',
137:                 lifespan: 1,
138:                 parameters: {}
139:             });
140:         }
141:         else {
142: 
143:             agent.add('Well done!');
144:             agent.add("You've choosen " + flavors +
145:                 ' So finally do you prefer your ice cream in a cup or a cone?');
146: 
147:             // using object literal property value shorthand
148:             let parameters = {
149:                 scoops,
150:                 flavors
151:             };
152: 
153:             agent.setContext({
154:                 name: 'awaiting_container',
155:                 lifespan: 1,
156:                 parameters
157:             });
158:         }
159:     }
160: 
161:     function handleTakeContainer(agent) {
162: 
163:         // extract container from intent parameters
164:         let container = agent.parameters.container;
165: 
166:         // extract number of scoops and list of flavors 
167:         // from context 'awaiting_container' parameters
168:         let context = agent.getContext('awaiting_container');
169:         console.log('==> Context ==> awaiting_container ==>  ' + JSON.stringify(context));
170:         let scoops = context.parameters.scoops;
171:         let flavors = context.parameters.flavors;
172: 
173:         let spokenScoops = (scoops === 1) ? "scoop" : "scoops";
174: 
175:         agent.add("Fine, you've selected a " + container);
176:         agent.add('So let me just summarize: You like to eat ' + spokenScoops +
177:             ' scoops in a ' + container + '. Your flavors are ' + flavors +
178:             '. Please say Yes if this is correct, otherwise No');
179: 
180:         // using object literal property value shorthand
181:         let parameters = {
182:             container,
183:             scoops,
184:             flavors
185:         };
186: 
187:         agent.setContext({
188:             name: 'customer_order',
189:             lifespan: 1,
190:             parameters
191:         });
192: 
193:         agent.setContext({
194:             name: 'awaiting_confirmation',
195:             lifespan: 1,
196:             parameters: {}
197:         });
198:     }
199: 
200:     // run the proper function handler based on the matched Dialogflow intent name
201:     let intentMap = new Map();
202:     intentMap.set('PlaceOrder', handlePlaceOrder);
203:     intentMap.set('TakeScoops', handleTakeScoops);
204:     intentMap.set('TakeFlavors', handleTakeFlavors);
205:     intentMap.set('TakeContainer', handleTakeContainer);
206:     agent.handleRequest(intentMap);
207: });

Beispiel 1. Firebase Cloud Function dialogflowFirebaseFulfillment


Auf einige wesentliche Abschnitte in Listing 1 gehen wir nun detaillierter ein: Die Zeilen 3 bis 5 dienen zunächst dem Zweck, die JavaScript-Module für dialogflow-fulfillment, firebase-functions und firebase-admin in entsprechenden Variablen ansprechbar zu machen. Für den Zugriff auf die Realtime-Database ist ein Aufruf von initializeApp abzusetzen. Die Zeichenkette des databaseURL-Parameters erhalten Sie, wenn Sie im Firebase-Portal einen Realtime-Database-Zugang freischalten. Das dort vorhandene https:-Protokoll ist allerdings durch das WebSocket-Protokoll ws: zu ersetzen. Springen wir jetzt in die Zeilen 201 bis 207:

// run the proper function handler based on the matched Dialogflow intent name
let intentMap = new Map();
intentMap.set('PlaceOrder', handlePlaceOrder);
intentMap.set('TakeScoops', handleTakeScoops);
intentMap.set('TakeFlavors', handleTakeFlavors);
intentMap.set('TakeContainer', handleTakeContainer);
agent.handleRequest(intentMap);

Jedem Intent, für das eine fulfillment-Realisierung vorgesehen ist, ist hierfür in einem Map-Objekt eine Methode zuzuordnen, die sich der Bearbeitung dieses Intents annimmt. Der erste Parameter der set-Methode beschreibt den Namen des Intents, der zweite die zugeordnete Methode. In unserem Beispiel können wir beobachten, dass für das PlaceOrder-Intent die Methode handlePlaceOrder vorgesehen ist.

Damit geht es weiter in Zeile 20. Pro Aktivierung eines fulfillments legen wir ein WebhookClient-Objekt an. An diesem Objekt stehen eine Reihe nützlicher Methoden zur Verfügung. Mittels der add-Methode lassen sich Zeichenketten übergeben, die der Google-Assistent in natürlicher Sprache am Client ausgibt. Die getContext-Methode wiederum ermöglicht den Zugriff auf ein (aktiviertes) Kontext-Objekt. Der gesuchte Kontext ist als Zeichenkette an getContext zu übergeben. Dieses Kontext-Objekt enthält ein Unterobjekt parameters. In diesem finden sich die Werte aller Parameter vor, die das aktuelle Intent besitzt. Auf diese Weise lassen sich nun im JavaScript-Code die Parameter des Intents weiter verarbeiten. Wir stellen in unserer Realisierung mit den aktuellen Parameterwerten ein JavaScript-Objekt zusammen, das wir in der Realtime-Database von Firebase ablegen wollen:

let order = {
    container,
    scoops,
    flavors,
    timeOfOrder : admin.database.ServerValue.TIMESTAMP
};

Hinweis: In der Schreibweise des JSON-Objekts order kommt die in ECMAScript 6 eingeführte „Object literal property value shorthand“-Notation zum Einsatz.

Die Werte hierfür haben in den Zeilen 28 bis 30 eingesammelt. Der admin.database.ServerValue.TIMESTAMP-Wert ist dabei ein Platzhalter, der während der server-seitigen Ausführung mit dem aktuellen Zeitstempel ersetzt wird. Schließlich kann man an einem WebhookClient-Objekt auch Ausgangskontexte setzen. Hierfür gibt es die Methode setContext. Diese erwartet den Namen des Ausgangskontexts, seine Lifespan und optional eine Reihe von Parametern, siehe zum Beispiel die Zeilen 39 bis 43 oder 111 bis 115.

In den Zeilen 57 bis 90 finden Sie die Realtime-Database-relevanten Anweisungen vor. Es wird zunächst eine eindeutige Bestellnummer erzeugt. Danach können wir die aktuell vorliegende Bestellung noch gleich um ihre Bestellnummer ergänzen, bevor wir das gesamte Objekt in der Realtime-Database ablegen. Einen Überblick über die generelle Struktur der NoSql-Datenablage finden Sie in Abbildung 1 vor. Unter dem Pfad control/lastestId legen wir die jeweils letzte Bestellnummer ab. Pro Bestellung inkrementieren wir diesen Wert – natürlich unter Beachtung eines möglicherweise konkurrierenden Zugriffs mehrerer Kunden – transaktionsgesichert.

Die Bestellungen selbst sind in einer Liste unterhalb des Einstiegsknotens orders abgelegt. Pro Bestellung finden wir hier alle relevanten Daten wie die Details zur Bestellung selbst, die Bestellnummer (pickupName) als auch den server-seitigen Zeitstempel vor. Im Quellcode können wir wiederum erkennen, dass wir vor der Ablage der Bestellung transaktionsbasiert eine eindeutige Bestellnummer generieren.

Prinzipielle Struktur der Firebase Realtime-Database.

Abbildung 1. Prinzipielle Struktur der Firebase Realtime-Database.


Nur als Hinweis zu sehen: Auch das Ablegen der einzelnen Bestellungen ist in Firebase vor dem konkurrierenden Zugriff geschützt. Die push-Methode in Zeile 73 generiert einen eindeutigen Knoten, unterhalb dessen die Daten einer einzelnen Bestellung abgelegt werden können.

In Listing 1 finden Sie an mehreren Stellen Ausgaben in eine Konsole vor – siehe zum Beispiel die Zeilen 21, 26, 67 usw. Die Ausgaben der console.log-Methode können Sie im Firebase Portal unter der Rubrik „Functions“ nachverfolgen. Im Dashboard des Portals (Abbildung 2) finden Sie zunächst den Namen dialogflowFirebaseFulfillment der protokollierten Methode vor:

Dashboard von Firebase Functions.

Abbildung 2. Dashboard von Firebase Functions.


Dies ist nicht weiter überraschend, da wir diese Methode in Zeile 15 von Listing 1 selbst implementiert und exportiert haben. Unterhalb des Reiters „Logs“ geht es dann weiter zu den Log-Ausgaben, siehe ein Beispiel in Abbildung 3:

Logging-Ausgaben bei der Ausführung von Firebase Cloud Functions.

Abbildung 3. Logging-Ausgaben bei der Ausführung von Firebase Cloud Functions.


Die Menge an Log-Ausgaben in der Konsole erscheint auf den ersten Blick vielleicht etwas unübersichtlich. Außerdem lassen sich die vorhandenen Einträge im Log-Fenster nicht ohne weiteres löschen. Eine kleine Abhilfe schafft die Möglichkeit, die Log-Ausgaben auf einen einzelnen Funktionsaufruf einzuschränken. Dazu muss man bei einer Zeile im Log-Protokoll rechts das Popup-Menü mit dem Menü-Eintrag „Alle aus dieser Ausführung anzeigen“ selektieren. Damit erhalten Sie beispielsweise eine Ausgabe wie in Abbildung 4 vorgestellt wird:

Logging-Ausgaben eines einzelnen Funktionsaufrufs.

Abbildung 4. Logging-Ausgaben eines einzelnen Funktionsaufrufs.


Zum Abschluss betrachten wir die Zeilen 93 bis 198 von Listing 1 genauer. Wir finden in diesem Abschnitt drei Methoden handleTakeScoops, handleTakeFlavors uns handleTakeContainer vor, die den drei Intents TakeScoops, TakeFlavors und TakeContainer zugeordnet sind. Welchen Zweck erfüllen diese drei Methoden? Die Intents selbst sind auf der Basis von Kontexten der Reihe nach zu aktivieren. Dialogflow frägt den Benutzer zuerst nach der Anzahl der gewünschten Eiskugeln, danach nach den gewünschten Geschmackssorten und wiederum danach nach der Art des Behälters. Wie lassen sich die jeweils ermittelten Informationen (Anzahl Kugeln, Geschmackssorten und Art des Behälters) von Intent zu Intent weiterreichen?

Dies ist eine typische Aufgabe für das Backend. Über den Eingangs-Kontext lässt sich der jeweils aktuelle Parameter auslesen (Anzahl Kugeln oder Geschmackssorten oder Art des Behälters). Einem Ausgangskontext wiederum lassen sich ebenfalls Parameter zuweisen. Auf diese Weise kann man ein- oder mehrere Werte von Intent zu Intent weiterreichen. In Zeile 111 beginnen wir dieses Spiel mit der Anweisung

agent.setContext({
    name: 'awaiting_flavors',
    lifespan: 1,
    parameters: { scoops }
});

Der Ausgangskontext awaiting_flavors bekommt den Wert des Parameters scoops zugeordnet. Danach geht es in den Zeilen 148 bis 157 weiter mit den Anweisungen

let parameters = {
    scoops,
    flavors
};

agent.setContext({
    name: 'awaiting_container',
    lifespan: 1,
    parameters
});

Hier haben wir bereits zwei Werte (Anzahl Kugeln und Geschmackssorten) eingesammelt, die wir jetzt aber dem Ausgangskontext awaiting_container mit auf dem Weg geben. In Zeile 181 bis 185 haben wir die gesamten Bestellinformationen zusammen und reichen diese über den Kontext customer_order an den Intent ConfirmOrderYes weiter:

let parameters = {
    container,
    scoops,
    flavors
};

agent.setContext({
    name: 'customer_order',
    lifespan: 1,
    parameters
});

Wir sind am Ende der Betrachtungen eines fulfillments in Dialogflow angekommen. Sie können – nebenbei bemerkt – einen Dialog auf Ihrem Android-Smartphone auch ohne umfangreiche Registrierungen bei Google testen. Dazu müssen Sie

  • in den Einstellungen im Abschnitt Allgemeines in den Spracheinstellungen die englische Sprache auswählen und

  • in den Einstellungen im Abschnitt Google Account denselben Account aktiviert haben, mit dem Sie im Dialogflow-Portal arbeiten.

Aktivieren Sie jetzt auf ihrem Android-Smartphone den Google Sprachassistent und geben „Talk to my test app“ ein! Nun können Sie mit Äußerungen des Welcome-Intent einen Dialog in die Wege leiten. Viel Spaß dabei!

Im abschließenden Teil dieser Trilogie wollen wir es dem Kunden der Eisdiele ermöglichen, seine Bestellung auf einem Android-Device zu verfolgen.