Skip to Content

In January, SAP announced the acquisition of Recast.ai, the Paris-based chatbot platform. This will undoubtedly speed up SAP’s forays into the areas of NLP and conversational interfaces.

Although Recast is not yet fully integrated into the SAP Cloud Platform, we can still use it for SAP-centric scenarios (like with most other chatbot providers). This blog is an attempt to show a simplified scenario, using a Recast chatbot as frontend and an OData service exposed on SAP Gateway as backend. The chatbot will react to queries from our user, and extract backend data in response.

It is important to understand that this scenario is “simplified” in that we won’t discuss topics such as authentication or anonymization of data. Hopefully, there will be a version 2 somewhere down the line, where I present a fully “compliant” chatbot. By anonymization of data, I refer to the fact that we – in this simplified scenario – send data from our backend OData service to the Recast.ai cloud platform for “embedding” into the chatbot responses. This is not really acceptable in most corporate scenarios (we’re using test data, so it doesn’t really matter for our proof-of-concept). One solution we’re working on here is to use a “bot connector” that will tokenize sensitive data and avoid it being sent openly outside our firewall.

Finally, this blog focuses mostly on the technical components of the chatbot scenario, not so much on building and training the Recast.ai chatbot itself, which should be fairly trivial by comparison. Recast has a lot of tutorials and blogs on this – highly recommended.

Anyway, let’s have a look at what we’ve come up with so far.

Ingredients

We need:

  • A nice use case
  • A Recast.ai chatbot
  • A suitable OData service
  • SAP-CP, CloudFoundry edition
  • SAP-CP, NEO edition

(At this point, you may ask yourself why we need the two SAP-CP editions. Actually, we don’t – at least not in an ideal world. Our world, however, is not perfect. At the time of work, our SAP-CP CloudFoundry edition was not equipped with connections to our Gateway server, hence the need for a Neo tenant in the mix. Clarifications to follow).

The Use case

We want to know how many employees we have according to specific criteria. Like, how many expatriates work in France? Or how many male internals are there in Italy? The chatbot will identify any of the criteria (gender, country, job category…) and translate the values to be used by our OData service. Simple, right?

Quite a few, it turns out.

The Recast chatbot

Let’s build a chatbot! We log in to the Recast.ai platform, create an account (if we don’t already have one), and start building.

Note: This section assumes you have some basic technical knowledge about chatbots, most notably the concepts of “intents” and “entities”. You should also be familiar with Recast.ai, specifically the “skills” and “requirements” features. A brief knowledge of webhooks is also useful.

Our chatbot has one main Intent, one (main) Skill, and six Entities. The Entities are the different search criteria the user may apply in the conversation, like “country” and “gender”. For country, we employ the “golden entity” Location – which comes for free with Recast bots. it basically means that the Recast NLP will try to identify any geographically-sounding utterings by the user to a specific location.

The main intent is “headcount”. This is where the chatbot kicks into action and tries to provide an answer based on the data it extracts from the OData service. We have a few utterings for our intent:

There are a few more, but it’s rather boring to read a long list of similarly-sounding phrases. Honestly.

Finally, we have a Skill. Well, Actually I’m lying. We have 5 skills for our bot, but only one is important. This is the aptly named “headcount” skill, which is triggered whenever the “headcount” intent has been identified:

Skills are cool. In Recast terminology, a Skill is like a small sub-set of things your chatbot can do. Almost like a, well, uh, skill.

A skill is triggered by something – in our case, as mentioned, the presence of the “headcount” intent:

In addition, the skill require some conditions to be met:

There are 6 requirements for our skills – corresponding to the 6 search criteria we use for our OData service. Note that these requirements are “OR”-ed together – meaning we can supply any number of them (from 1 to all 6). This allows us to use fairly complex questions!

When the skill is completed, it fires a webhook:

The webhook will be intercepted by our backend application, residing on SAP-CP CloudFoundry. This is a Node.js app, based on the template provided by Recast (see Recast.ai documentation for more info on webhooks). In brief, it works like this: The chatbot calls the backend Node.js app, using the endpoint defined in our skill. The conversation context (or chatbot memory) is automatically passed along. All we have to do on the backend side is examine the memory contents, extract the entity (search) values, and fire the call to our OData service.

Let’s take a momentary break from the chatbot universe and look at our OData service.

The OData service

We have set up a suitable OData service that allows us to filter on certain criteria. In our case, the service is a BW Query equipped with the “OData” tag – meaning it has been exposed as a service. We could have used any ABAP-based (or XS) OData service.

The JSON format of our service looks something like this:

And here is an example of testing it in our backend system, using two filter criteria values:

The result is a simple number:

OK. It has decimals. We’re employing fractions of people.

Not really – the number is a weighted average. We’ll simply have to round it.

The SAP-CP CF Node.js webhook app

Now, on to SAP-CP, CloudFoundry edition. Here is where most of the work is done.

We fire up the SAP-CP Full Stack Web IDE:

(Wow! Thanks Ran & co for making this cool logo!!)

Here, we have our index.js file, which is based on the webhook template from Recast. It is lengthy and a bit complex, for the following reasons:

We need to do some conversions of entity values. Example: if the user says “France”, we need to convert this to a proper country code know by our backend service, ie. “FR”.

We also need to “remember” the values uttered by the user until the next question comes around. This is because we want to facilitate scenarios like the following:

“How many people work in Italy”? <– Here, the chatbot would reply something like “500”.

“How many are male expats?” <– Here, the chatbot should understand that we’re still talking about Italy

“How about Spain?” <– Here, the chatbot should provide the number of male expats in Spain, not just the total number of employees!

For the sake of simplicity, I provide the main bulk of the code below, with comments. I’ve obfuscated some parts, like the exact name of backend services.

const express = require('express');
const bodyParser = require('body-parser');
const recastai = require('recastai').default;
const requestify = require('requestify');
const request = new recastai.request('<YOUR RECAST ID>');
const ccode = require('ccode.js');

const app = express();
app.use(bodyParser.json());
app.set('port', (process.env.PORT || 5000));

app.get('/', function(req, res) {
	res.send('Use the /api/askBot endpoint.');
});
app.get('/api/askBot', function(req, res) {
	res.send('You must POST your request');
});

app.post('/api/getEmployees', function(req, res) {


	var entities = req.body.nlp.entities;
	var memory = req.body.conversation.memory;
	

	//We need to store the conversation values in internal variables before converting to SAP keys...
	//This is because we will repeat some of the values back to the user, and we don't want this to be the technical values used for search etc.
	var replyGender = (entities.gender !== undefined) ? entities.gender[0].value : ( (memory.replyGender !== undefined) ? memory.replyGender : '' );
	var replyGroup = (entities.group !== undefined) ? entities.group[0].value : ( (memory.replyGroup !== undefined) ? memory.replyGroup : '' );
	var replyStatus = (entities.status !== undefined) ? entities.status[0].value : ( (memory.replyStatus !== undefined) ? memory.replyStatus : '' );
	var replyZone = (entities.zone!== undefined) ? entities.zone[0].value : ( (memory.replyZone !== undefined) ? memory.replyZone : '' );
//	var replyCountry = (entities.location !== undefined) ? entities.location[0].raw : ( (memory.replyCountry !== undefined) ? memory.replyCountry : '' );
	

	//Scoop up all the countries
	var cleansedCountries = [];
	var replyCountries = [];

	if (entities.location !== undefined) {
		for (let i = 0; i < entities.location.length; i++) {
			replyCountries.push(entities.location[i].raw);
			cleansedCountries.push(ccode.CConvert(entities.location[i].raw.toUpperCase()));
		}
		console.log('Here are all of your country codes:' + cleansedCountries);
	}
	else {
		//If no new locations provided, get the ones already in memory
		cleansedCountries = memory.cleansedCountries;
		replyCountries = memory.replyCountries;
	}

	
	console.log('replyGender = ' + replyGender);
	console.log('replyGroup = ' + replyGroup);
	console.log('replyStatus = ' + replyStatus);
	console.log('replyZone = ' + replyZone);
	console.log('replyCountries = ' + replyCountries);
	
	
	//Convert entity values to be cleansed into SAP search criteria
	//If we got anything from the entities (conversation), we translate to upper case, otherwise set to "*"
	var newGender = (entities.gender !== undefined) ? entities.gender[0].value.toUpperCase() : '*';
	var newGroup = (entities.group !== undefined) ? entities.group[0].value.toUpperCase() : '*';
	var newStatus = (entities.status !== undefined) ? entities.status[0].value.toUpperCase() : '*';
	var newZone = (entities.zone!== undefined) ? entities.zone[0].value.toUpperCase() : '*';
	

	console.log('newGender = ' + newGender);
	console.log('newGroup = ' + newGroup);
	console.log('newStatus = ' + newStatus);
	console.log('newZone = ' + newZone);
	
	
	//Cleansing: Recast does not currently support mapping of entity values to "master" values, so we have to do it manually.
	//This means all A0 values will be mapped - or remain "*" if no specific new value was provided by the user.
	//Note: for each iteration, we only need to map any "new" entity value provided by the user.
	
	if (newGender !== '*') {
		newGender = ccode.GConvert(newGender);
	}
	
	console.log("The translated gender value is: " + newGender);

	if (newGroup !== '*') {
		newGroup = ccode.GrpConvert(newGroup);
	}
	
	console.log("The translated employee group value is: " + newGroup);
	
	if (newZone !== '*') {
		newZone = ccode.ZConvert(newZone);
	}
	
	console.log("The translated zone value is: " + newZone);
	
	
	//Store all the "cleansed" variables in our own memory, since the Recast mapping doesn't work properly
	//We have to check whether the cleansed variables are already there, otherwise we grab the new values
	//from the cleansed entity values.
	
	
	//cleansedValue will be set to A0 value if A0 value not "*". Else, cleansedValue will remain unless it is undefined (then, it is set to "*")
	var cleansedGender = (newGender !== "*") ? newGender : ( (memory.cleansedGender !== undefined) ? memory.cleansedGender : "*" );
	var cleansedGroup = (newGroup !== "*") ? newGroup : ( (memory.cleansedGroup !== undefined) ? memory.cleansedGroup : "*" );
	var cleansedStatus = (newStatus !== "*") ? newStatus : ( (memory.cleansedStatus !== undefined) ? memory.cleansedStatus : "*" );
	var cleansedZone = (newZone !== "*") ? newZone : ( (memory.cleansedZone !== undefined) ? memory.cleansedZone : "*" );

	console.log('cleansedGender = ' + cleansedGender);
	console.log('cleansedGroup = ' + cleansedGroup);
	console.log('cleansedStatus = ' + cleansedStatus);
	console.log('cleansedZone = ' + cleansedZone);
	

	cleansedCountriesJSON = JSON.stringify(cleansedCountries);

	// Call the backend service 
	requestify.request('https://YOURSAPCPTENANT.hana.ondemand.com/etc/etc/blabla/OurPreciousBot/services/GetHeadcount.xsjs', {
			method: "GET",
			params: {
			"A0GENDER": cleansedGender,
			"A0EMPLGROUP": cleansedGroup,
			"A0EMPLSTATUS": cleansedStatus,
			"YGZONE": cleansedZone,
			"A0COUNTRY": cleansedCountriesJSON
			}

	})
	.then(function(response) {

		console.log("Got a response from the request");
		
		var replyFrom = "";
		if (replyZone !== "" || replyCountries !== "") {
			replyFrom = " in " + replyZone;
			
			for (let i = 0; i < replyCountries.length; i++) {
				replyFrom += replyCountries[i];
				if (i < (replyCountries.length - 1)) {
					replyFrom += " and ";
				}
			}
	
		}
		
		console.log("replyFrom = " + replyFrom);
		
		var replyContent = "The number of " +
		replyGender + " " + replyStatus + " " + replyGroup + 
		" employees " + replyFrom + " is: " + response.getBody();
		
		replyContent = replyContent.replace(/  +/g, ' ');
		
		var reply = [{
			type: 'text',
			content: replyContent
		}];

		console.log("The response.getBody() is: " + response.getBody());

		res.status(200).json({
			replies: reply,
		    conversation: {
      			memory: { 	cleansedGender: cleansedGender,
      					  	cleansedGroup : cleansedGroup,
      						cleansedStatus : cleansedStatus,
      						cleansedZone : cleansedZone,
      						cleansedCountries : cleansedCountries,
							replyGender: replyGender,
      					  	replyGroup : replyGroup,
      						replyStatus : replyStatus,
      						replyZone : replyZone,
      						replyCountries : replyCountries 
      			}
    		}
		});
	}, function(error) {
			var errorMessage = "GET to XSJS service failed";
			if(error.code && error.body) {
				errorMessage += " - " + error.code + ": " + error.body
			}
			console.log("Something went wrong with the call");
			console.log(errorMessage);
			// dump the full object to see if you can formulate a better error message.
			console.log(error.body);
			
			//Try to provide a proper error response
			
			var reply = [{
				type: 'text',
				content: "I'm sorry! Something went wrong with the call to the SAP query. Try asking a different question - or type 'reset'."
			}];

			res.status(200).json({
				replies: reply,
				conversation: {
					memory: { 	cleansedGender: cleansedGender,
								cleansedGroup : cleansedGroup,
								cleansedStatus : cleansedStatus,
								cleansedZone : cleansedZone,
								cleansedCountries : cleansedCountries,
								replyGender: replyGender,
								replyGroup : replyGroup,
								replyStatus : replyStatus,
								replyZone : replyZone,
								replyCountries : replyCountries 
					}
				}
			});			
			
		}
	);
});
 

app.post('/api/askBot', function(req, res) {

	var sMessage = req.body['message'];

	client.analyseText(sMessage)
		.then(function(response) {
			if (response.intent()) {
				console.log('Intent: ', res.intent().slug)
			}

			if (response.intent().slug === 'YOUR_EXPECTED_INTENT') {
				// Do your code...
			}
		})

});

app.listen(app.get('port'), function() {
	console.log('* Webhook service is listening on port:' + app.get('port'));
});

The code also relies on a homegrown Node module for converting specific conversational values (countries, gender…) to something our OData service can understand. you can see it in places like:

newGender = ccode.GConvert(newGender)

The module is really simple – it contains 4 conversion functions, all more or less like this:

You probably already noticed that this module is loaded on line 6 of the index.js code:

const ccode = require(‘ccode.js’);

What happens in the above index.js code is actually fairly simple. We retrieve the entity and memory values from the chatbot conversation:

var entities = req.body.nlp.entities;
var memory = req.body.conversation.memory;

We then check if there are any new countries provided by the user. If not, we retrieve the ones we currently have in memory – if any. Same for other entity values. We then cleanse the values – for instance, convert country names to country codes, or gender values (male/female) to the values 1/2.

All of this done, we stuff the entity values into the conversation memory – in both “cleansed” and “raw” format. This is because we want to keep both the “cleansed” values for the backend search, and the “raw” format values for use in the conversation with the user. Note that for some values, like countries, we build an array since there can be more than one value.

Then, we call our XSJS service – the “placeholder” for our OData service – with the required parameters, check the result, and send the “reply” back to the chatbot. The reply is simply a string where we have constructed our response in human conversation-like format.

The XSJS service on SAP-CP NEO

The XSJS service called from our Node.js app on SAP-CP CF is, as mentioned, needed as a temporary “abomination” – since we at the time of writing didn’t have a proper connection set up from SAP-CP CF to Gateway. This will be amended in the future, but for now, let’s have a look at this step.

As per above, we fire a call to the XSJS service from our Node.js app. Here is the XSJS service (slightly abbreviated and with some obfuscations of schema names, variables etc.):

//Get headcount for Recast HRBot

try {

	switch ($.request.method) {

		case $.net.http.GET:

			var A0GENDER = '*';
			var A0EMPLGROUP = '*';
			var A0EMPLSTATUS = '*';
			var YGZONE = '*';
			var A0PERS_AREA = '*';
			var A0COUNTRY = '*';

			for (let i = 0; i < $.request.parameters.length; i++) {
				switch ($.request.parameters[i].name) {
					case "A0GENDER":
						A0GENDER = $.request.parameters[i].value;
						break;
					case "A0EMPLGROUP":
						A0EMPLGROUP = $.request.parameters[i].value;
						break;
					case "A0EMPLSTATUS":
						A0EMPLSTATUS = $.request.parameters[i].value;
						break;
					case "YGZONE":
						YGZONE = $.request.parameters[i].value;
						break;
					case "A0PERS_AREA":
						A0PERS_AREA = $.request.parameters[i].value;
						break;
					case "A0COUNTRY":
						A0COUNTRY = $.request.parameters[i].value;
						break;
				}
			}
			var destination = $.net.http.readDestination("OurMagnificentBot.services", "gatewayXXX");
			//Creating an HTTP Client
			var client = new $.net.http.Client();

			var requestString = '?$select=A006..............SZ2NM&$format=json';
			var filterString = "empty";

			if (A0GENDER !== '*') {
				filterString = "A0GENDER%20eq%20'" + A0GENDER + "'";
			}

			if (A0EMPLGROUP !== '*') {
				if (filterString === "empty") {
					filterString = "A0EMPLGROUP%20eq%20'" + A0EMPLGROUP + "'";
				} else {
					filterString += "%20and%20A0EMPLGROUP%20eq%20'" + A0EMPLGROUP + "'";
				}
			}

			if (A0EMPLSTATUS !== '*') {
				if (filterString === "empty") {
					filterString = "A0EMPLSTATUS%20eq%20'" + A0EMPLSTATUS + "'";
				} else {
					filterString += "%20and%20A0EMPLSTATUS%20eq%20'" + A0EMPLSTATUS + "'";
				}
			}

			//Now catering for multiple countries
			if (A0COUNTRY !== '*') {
				A0COUNTRY = JSON.parse(A0COUNTRY);

				if (A0COUNTRY.length > 0) {
					if (filterString === "empty") {
						filterString = "(A0COUNTRY%20eq%20";
					} else {
						filterString += "%20and%20(A0COUNTRY%20eq%20";
					}
					for (var i = 0; i < A0COUNTRY.length; i++) {
						filterString += "'" + A0COUNTRY[i] + "'";
						if (i < (A0COUNTRY.length - 1)) {
							filterString += "%20or%20A0COUNTRY%20eq%20";
						}
					}
					filterString += ")";
				}
			}

			if (filterString !== "empty") {
				requestString += "&$filter=(";
				requestString += filterString;
				requestString += ")";
			}


			var request = new $.net.http.Request($.net.http.GET, requestString);

			//Setting the headers
			request.headers.set("SAP-Connectivity-SCC-Location_ID", "[LOCATION_ID]");
			request.contentType = "text/plain";

			//Fire the request
			client.request(request, destination);

			var gwResponse = client.getResponse().body.asString();
			var JSONObj = JSON.parse(gwResponse);
			var botResponse;

			botResponse = (JSONObj.d.results.length > 0 ? Math.round(JSONObj.d.results[0].A006................Z2NM) : 0);

			$.response.status = $.net.http.OK;
			$.response.contentType = "application/json";
			$.response.setBody(botResponse);

			break;
		default:
			$.response.status = $.net.http.METHOD_NOT_ALLOWED;
			$.response.setBody("Wrong request method");

			break;
	}

} catch (e) {
    
	$.response.setBody("Failed to execute: " + e.toString());
}

As can be seen, this is a fairly straightforward XSJS service. It relies on an .xshttpdest file (the “Ourmagnificentbot…” file referred to in the code) – this file basically reproduces the values from our “destination” to the Gateway system as defined on the SAP-CP. As you may know, pure XSJS services cannot use pre-defined destinations, hence the need for an .xshttpdest file. The general format is as follows:

The XSJS service is set up to use basic authentication – an RFC user on the Gateway hub. This is configured in the XS Admin console – reachable by clicking the “Maintain credentials” button in the Web IDE on SAP-CP after you select the .xshttpdest file:

For the XSJS service itself, you will have to provide appropriate authentication. For a PoC, using an anonymous service is OK:

Of course, you will have to set up an .xssqlcc file with the name from the .xsaccess file above, and “tie” this anonymous user to a technical DB user – this is explained in various blogs elsewhere.

Conclusion

With all these components properly in place, you can start interacting with your bot!

Feel free to ask questions. As mentioned, this is a first version of a Recast chatbot with Gateway OData services. Some vital features are lacking, such as data anonymization, and authentication. Nor have I showed how to deploy the chatbot in a channel such as a web page, Slack etc. (but this should be fairly trivial and is well documented on the Recast.ai site). My main aim was to show the basic components of the chatbot-to-backend interaction.

Happy chatbot-building with Recast!

 

To report this post you need to login first.

5 Comments

You must be Logged on to comment or reply to a post.

  1. Murali Shanmugham

    Hi Trond, Thanks for taking the effort to share your experience on the PoC. I have been asked the same question several times and there is no documentation yet as to how all the pieces connect together.

    One of the challenges I see with the usage of this chatbot is Principal Propagation. I heard the functionality is coming soon. For example, if you want the chatbot to give you your leave balance, it needs to know who the user is and fetch their user records from the backend system.

    I am pretty sure we should be able to connect SAP Gateway system to CF environment via Cloud Connector (thou its not as easy as Neo environment). This way you should be able to directly invoke the Gateway APIs from your nodejs app.

    Looking forward for your updates on this blog in the coming months.

     

    (0) 
  2. Trond Stroemme Post author

     

    Hi Murali,

    thanks for your comments! One reason I haven’t included the principal propagation topic in my PoC is because we’re waiting for the new version of the Recast bot connector, scheduled for July 20. We started doing some in-house work on a similar app, which we dubbed the “Botfather”, which would handle authentication and also tokenization of sensitive data (using a redis instance on SAP-CP for intermediary storage of business data), but we’re now eagerly awaiting July 20 in order to progress on this important topic. Hope to resent version 2 in a few week’s time!

    Btw, also hoping for the future incorporation of Recast into the SAP-CP itself, which would simplify a lot of things.

    Regards,

    Trond

    (0) 
  3. Santi Rai

    Trond Stroemme,

    Where did you host your nodejs script (index.js)? What is the reason behind, to write XSJS services and nodejs app? Can we expose SAP HANA data, just via XSJS services?

    And how do we import these library? Because, you have not mention those  library above.

    const express = require(‘express’); const bodyParser = require(‘body-parser’); const recastai = require(‘recastai’).default; const requestify = require(‘requestify’); const request = new recastai.request(‘<YOUR RECAST ID>’); const ccode = require(‘ccode.js’);

     

    (0) 
    1. Trond Stroemme Post author

       

      Hi Santi,

      the node.js script is hosted on SAP-CP (CloudFoundry). You can deploy it using the CF CLI (command-line interface), available at cloudfoundry.org, or directly from the SAP-CP Multi-stack Web IDE (you have to select your SAP-CP CF tenant as the target for your app). The dependencies should be resolved on deployment. You can also check the Recast.ai documentation on this topic – or the tutorials like this one: https://recast.ai/blog/nodejs-chatbot-movie-bot/

      The “Recast ID” is available on the settings page for your bot.

      Finally, the “ccode.js” module is one I created myself – there’s a screenshot of a part of it in the blog.

      (0) 

Leave a Reply