Alexa and Sitecore Integration - Part 3

The Story So Far


In the previous posts I've described how to enable and use the OData Item Service in Sitecore and also how to set up a Alexa skill. In this final part of the series on Alexa and Sitecore integration - we finally come to the point where we plug in the newest part of Sitecore 9 - namely XConnect. This will allow us to collect data about what is being searched for via our Alexa Skill. Therefore, we are able to glean useful insights into user behavior and use the collected data to personalise content.

Connecting the X's

If you've read enough about XConnect, you will know that when we collect data, we  associate that data with a contact. Ideally we also try to identify the contact.

So far ,we have not really discussed how we know who the user is. This is not really obvious for voice driven experiences as it is for web driven interfaces. For example, to identify a web user - a developer can force them to register or login and identify them with cookies. However, for a voice driven device such as Alexa, the process is slightly different and thankfully doesn't involve you verbally saying your email address or password to Alexa!

Since user interaction data is like gold dust (Bitcoin anyone?) to marketers - the likes of Amazon and Google have thankfully thought about identity management with their voice assistants. In short - it is possible for the user to associate their identity with a skill. Developers are then able to leverage the identity information to associate with collected data.

If you remember from the previous post - in order for your device (Alexa) to use a skill you have to add it to your list of skills via the Alexa mobile app. Doing so, merely enables the skill on the device but this does not yet identify the user to that skill.

To identify a user a skill must ask the user to login using the Alexa app. However, this flow needs to be configured on the Alexa Skill definition as I eluded to in the previous post. For my example, I have opted to use the Account Linking feature in conjunction with the Login With Amazon (LWA). LWA is an OAuth 2.0 service enabling you to log in with your Amazon credentials. Please note - you can also use Account Linking to connect to your own OAuth provider so you are not tied into using LWA.

You can find out more about Account Linking and LWA here:

https://developer.amazon.com/blogs/post/Tx3CX1ETRZZ2NPC/Alexa-Account-Linking-5-Steps-to-Seamlessly-Link-Your-Alexa-Skill-with-Login-wit

Once you've plugged in LWA or your chosen OAuth provider, the user is able to login in via the Alexa app. Upon successful authorization, a token is issued and this token can be used when the skill is invoked. The code associated with the skill will receive the authorization token and can send it back to the OAuth provider in order to retrieve profile information about the user such as email, name etc.

So now we have the pieces of the identity puzzle, let's see what the skill code looks like:

'use strict';
const odata = require('odata-client');
const util = require('util');

var api_host = process.env.API_HOST;
var api_key = process.env.API_KEY;
var profile_host = process.env.PROFILE_HOST;
var alexa_skill_id = process.env.ALEXA_SKILL_ID;

var userProfile = null;

console.log("API HOST" + api_host);

var Alexa = require('alexa-sdk');

//bootstrap Alexa
exports.handler = (event, context, callback) => {
    var alexa = Alexa.handler(event, context);
   //get the users profile info
   GetProfile(event, () => {
      alexa.APP_ID = alexa_skill_id;
      alexa.registerHandlers(handlers);
      alexa.execute();    
    });

};

function GetProfile(event, callback){
    
    //get profile data by passing access token
    var accessToken = event.session.user.accessToken; 

    var request = require('request');
    var amznProfileURL = 'https://' + profile_host + '/user/profile?access_token=';

    amznProfileURL += accessToken;

    request(amznProfileURL, function(error, response, body) {
        var profile = null;
        if (response.statusCode == 200) {

                userProfile = JSON.parse(body);
        }

        callback();
    });    

}

function RecordInteraction(slotValue){

    var request = require('request');
    var controller = 'http://' + api_host + '/api/sitecore/KeywordTracker/PostKeywords';

    var payload = {SlotValue : slotValue, Email: userProfile.email,  UserName : userProfile.name, SessionId: ""};

    request.post({url: controller, formData: payload}, function optionalCallback(error, response, body) {
       if (error) {
           console.error('recording interaction failed:', error);
        }

        if (response.statusCode == 200){
           console.log('interaction recorded');
        }
     });
}

var handlers = {

    'LaunchRequest': function () {
        if (userProfile != null)
            this.emit(":tell","Welcome back");
    },
    'Unhandled': function () {

        this.emit(':tell', 'huh?');
    },

    ....
    'DogByName': function () {
        var handler = this;
        //get the name value spoken by the user
        var dogName = this.event.request.intent.slots.Name.value;

        if (dogName == null) {
            this.emit(":tell","Sorry, you did not provide a dog name");
            return;
        }

        var q = odata({
            service: "https://" + api_host + "/sitecore/api/ssc/aggregate/content",
            resources: "Items",
            custom: {
                sc_apikey: api_key,
                format: "json",
                "$filter": "TemplateName eq 'Dog'
                and Fields/any(f: f/Name eq 'Celebrity' and f/Value eq '" + dogName + "')",
                expand: "Fields(select=Celebrity,Description,Personality)"
            }
        });
        q.expand("Fields")
        q.get()
        .then(function (response) {

            var body = JSON.parse(response.body);
            if (body != null) {
                if (body.value && body.value.length > 0) {
                    console.log(body);
                    var result = "";

                    result += "<p>" + dogName + " is a " + body.value[0].Name + "</p>";
                    result += "<p>" + body.value[0].Fields[0].Value + "</p>";

                    handler.emit(":tell", "okay - " + result);
                }else{
                    handler.emit(":tell","Hmmm - I did not find a match by name");
                }
            }
        });
        RecordInteraction(dogName); //record interaction via XConnect

    },

....
}

The code above contains a number of changes. Firstly, the normal bootstrap code from the previous post has been updated to call GetProfile to fetch the user data from Amazon's OAuth service. If user data is returned - the global userProfile variable is populated and can be utilised by other bits of the code. You can see an example of this in the LaunchRequest function which says "Welcome back" when we invoke the skill (eg. Launch Sitecore). If we wanted to force the user to log in, we could check the userProfile variable upon executing each intent (function) and if this is null - we can emit a message asking them to log in instead of executing the function.

I've also added the RecordInteraction function to call a simple KeywordTracker controller within Sitecore that consumes the spoken slot value, user email address and name as parameters. The controller will then use XConnect to collect the data.

Finally - I've included the DogByName function. This is invoked when we ask about a dog by its name ("Tell me about a dog named {Jerry Lee}"). The slot value is stored in the dogName variable and substituted into the OData query. At the end of the DogByName function we call the RecordInteraction function to send the data to XConnect regardless of outcome.

Recording Data with XConnect

The above code gives you a rough idea of how to wire up the Alexa skill to Sitecore. Now let's take a look at the Sitecore and XConnect side of the equation.

To keep things short, I've created a simple KeywordTracker controller with a PostKeywords action. This controller simply initialises a InteractionManager class that does most of the work.

namespace Alexa.Api.XConnect.Controllers
{
    public class AlexaModel
    {
        public string SlotValue { get; set; }
        public string UserName { get; set; }

        public string Email { get; set; }

        public string SessionId { get; set; }
    }

    public class KeywordTrackerController : Controller
    {
        [HttpPost]
        public HttpStatusCodeResult PostKeywords(AlexaModel model)
        {
            var interactionMgr = new InteractionManager();

            if (interactionMgr.CreateInteraction(model.Email, model.UserName, model.SlotValue))
            {
                return new HttpStatusCodeResult(200);

            }

            return new HttpStatusCodeResult(401);

        }
    }


Now the InteractionManager class:

namespace Alexa.Api.XConnect
{
    public class InteractionManager
    {
        public bool CreateInteraction(string email, string name, string slotValue)
        {
            using (Sitecore.XConnect.Client.XConnectClient client = Sitecore.XConnect.Client.Configuration.SitecoreXConnectClientConfiguration.GetClient())
            {
                try
                {
                    var contactReference = new IdentifiedContactReference("alexa", email);
                    //get the contact if they exist
                    Contact contact = client.Get(contactReference, new ContactExpandOptions() { });

                    if (contact == null)
                    {
                         //create new contact and populate facets
                        var identifier = new ContactIdentifier[]
                        {
                           new ContactIdentifier("alexa", email, ContactIdentifierType.Known)
                        };

                        personalInfoFacet = new PersonalInformation();
                        EmailAddress emailAddr = new EmailAddress(email, true);
                        EmailAddressList addressList = new EmailAddressList(emailAddr, "Primary");
                        personalInfoFacet.FirstName = name.Split(' ')[0];
                        personalInfoFacet.LastName = name.Split(' ')[1];
                        contact = new Sitecore.XConnect.Contact(identifier);

                        client.SetFacet(contact, PersonalInformation.DefaultFacetKey, personalInfoFacet);
                        client.SetFacet(contact, addressList);

                        client.AddContact(contact);
                    }

                    Guid channelId = Guid.Parse("D147D0B1-59A8-433E-B07C-A297A7DE163D");

                    string userAgent = "Amazon Alexa";
                    //create interaction and set basic params
                    var interaction = new Sitecore.XConnect.Interaction(contact, InteractionInitiator.Contact, channelId, userAgent);
                    interaction.VenueId = Guid.Parse("240650AF-9266-4C9A-A288-841124704636");

                    // Event definition ID
                    Guid vocalSearchEventDef = Guid.Parse("{3A469E63-292A-494D-8FC1-48AAC8517505}");
                    //create new event and populate with slot value
                    var vocalSearchEvent = new VocalSearch(vocalSearchEventDef, DateTime.UtcNow)
                    {
                        SpokenValue = slotValue
                    };

                    //associate the event with the interaction
                     interaction.Events.Add(vocalSearchEvent);
                    //register the interaction
                    client.AddInteraction(interaction);
                    //send the interaction data to XConnect
                    client.Submit();

                    return true;
                }
                catch (Exception ex)
                {
                    return false;
                    // Handle exception
                }
            }
        }

    }
}

The above code looks slightly long but is fairly straight forward. First the XConnect client is instantiated and then we attempt to find an existing contact given the source (alexa) and the users email address. If a match is found we use the returned contact when creating the interaction later on. If the contact doesn't exist - we create a new one.

After finding or creating a contact an interaction object is instantiated with the contact, channel ID and user agent. I have also set Venue ID though I don't think this is mandatory.

Once the interaction is created - I have created a event object based on my custom Vocal Search event. For this event object, I have set the SpokenValue property to the slot value passed in by the KeywordTracker controller. If you are using your own custom event, you could also set other properties but for this demo I have kept things simple.  The event is then associated with the interaction by calling Add method on the Events collection of the interaction object.

Finally we register the interaction by calling AddInteraction on the XConnect client object after which we call the Submit method to finish the operation and return a true status to the controller or false if a failure if an exception occurs. Obviously - you will want to more exhaustive exception handling! Now we are ready to run some sample requests through Alexa or we could simply fake it by making requests directly to the KeywordTracker controller.

The Raw Data

We can see what our recorded data looks like by querying the Interaction table in the xdb.Collection.Shard databases. Below is the value of the Events column from the Interactions table.

[{
"@odata.type": "#XConnect.Alexa.Model.VocalSearch",
"CustomValues":[],
"DefinitionId":"3a469e63-292a-494d-8fc1-48aac8517505",
"Id":"2cb529f5-5e5b-481b-9f6c-40fbb7a88a62",
"Timestamp":"2017-11-13T22:03:31.4687091Z",
"SpokenValue":"jerry lee"}]

As you can see, the DefinitionId value is that of our VocalSearch event and the data also contains the SpokenValue property that we set in our InteractionManager class above.

Eventually - when the interaction data has been collected and XConnect indexes have been updated - you should see profile data appearing in the Experience Profile section of Sitecore.




As you can just about make out - the Vocal Search events have been recorded. What you can also see is that there is an error. This is because XConnect is currently configured to look for facets related to web visits. However, a vocal search through Alexa or other source is not the same as web visit and may not provide the same amount of data. So when there is missing data in web visit related facets - we see this error and also may find the following error in the logs:

23232 17:34:06 INFO  [Experience Analytics]: Interaction with id b1b364fd-6fd6-0000-0000-051f585aa06e will be ignored by XA dimensions. Interaction doesn't appear to be valid web visit. Either no page views or web facet is null or has empty SiteName

The Sitecore Knowledgebase on this matter states:

"The Experience Profile currently only supports website visit interactions. As a result, if an interaction does not fully populate the facets required for a web visit, an error is displayed."

Hopefully a fix for this issue will be coming in the next minor update. However, if you are not the patient type - you can always try this workaround from Alexei Vershalovich.

So this brings us to the end of our journey with Sitecore and Alexa. Where do we go from here you ask? Well the next logical step is to create a custom report based on the words spoken by the user - so that's my next adventure with XConnect. I hope you will join me.







Comments

Popular posts from this blog

Getting Up and Running with Sitecore 9

Introducing Helix DNA