Amazon Lex – Pedindo uma pizza de forma mais inteligente

Olá, pessoal! Tudo bem?

No último post, fizemos um exemplo bem simples utilizando o Amazon Lex, com o propósito de pedir uma pizza. Porém a configuração feita no exemplo não é muito flexível, e dificilmente seria utilizada em produção.

Neste post nós iremos mais além. A ideia é darmos mais poder ao Amazon Lex, através da integração com a AWS Lambda e alguns outros serviços da AWS.

Arquitetura exemplo

Agora que nós conhecemos os principais elementos do Amazon Lex, vamos definir o restante dos componentes que irão compor a nossa solução.

A arquitetura básica do nosso exemplo será a seguinte:

Arquitetura exemplo

A ideia é tornar o exemplo simples, então testaremos o chatbot no Postman, e, como o “cérebro” do bot, iremos utilizar uma Lambda que terá como data source o DynamoDB. Para algo mais production-ready, poderíamos integrar outros serviços, como o Cognito, usado para autenticação/autorização.

Arquitetura exemplo

Projeto

O projeto a seguir, foi desenvolvido usando o AWS Serverless Application Model, ou SAM. Para quem quiser conhecer, o código fonte está disponível no Github.

Estrutura do DynamoDB

Para deixar o exemplo mais completo, adicionamos uma tabela no DynamoDB (através do SAM), e incluímos o preço dos sabores de pizza e da bordas da pizza. Então a ideia é somar o valor de ambos e apresentar o valor final para o cliente.

Tabela de sabores com os preços

Uma observação importante aqui é que os sabores de pizza e borda também precisam ser usados nos slot types do Lex, para que os valores sejam utilizados no treinamento do bot.

Pizza Flavours

Lambda para inicialização e validação

A primeira coisa que faremos nesse exemplo, é a integração entre o Amazon Lex e a lambda que irá atuar na inicialização e validação dos dados das interações com o usuário. Para utilizá-la no Lex, precisamos selecioná-la em Lambda Initialization and Validation.

Lambda initialization and validation

A função desta integração é obter todas as informações (slots) necessárias para completar o intent. Não entrarei em detalhes sobre a estrutura das mensagens trocadas entre o Amazon Lex e a Lambda, porém a documentação oficial está disponível aqui. Abaixo segue o código fonte.

const aws = require("aws-sdk");

const createInvalidSlotResponse = (slots, slotToElicit, message) => {
    slots[slotToElicit] = null;
    return {
        dialogAction: {
            type: "ElicitSlot",
            message: {
                contentType: "PlainText",
                content: message
            },
            intentName: "OrderAPizza",
            slots,
            slotToElicit: slotToElicit
        }
    }
};

const createConfirmationResponse = (slots, totalPrice) => {
    return {
        sessionAttributes: {
            totalPrice
        },
        dialogAction: {
            type: "ConfirmIntent",
            message: {
                contentType: "PlainText",
                content: `Ok. Do you confirm your order for a ${slots.flavour} pizza, with ${slots.crust} stuffed edge to be delivered at ${slots.address} for U$${totalPrice.toFixed(2)}?`
            },
            intentName: "OrderAPizza",
            slots,
            responseCard: {
                version: 1,
                contentType: "application/vnd.amazonaws.card.generic",
                genericAttachments: [
                    {
                        title: "Order confirmation",
                        subTitle: "Can I place your order?",
                        buttons: [
                            {
                                text: "Yes",
                                value: "yes"
                            }, {
                                text: "No",
                                value: "no"
                            }
                        ]
                    }
                ]
            }
        }
    }
};

const createDelegateResponse = (slots) => {
    return {
        dialogAction: {
            type: "Delegate",
            slots
        }
    }
};

const getFlavourPrice = (type, flavour) => {
    const dynamoDB = new aws.DynamoDB({
        apiVersion: "2012-08-10",
        region: "us-east-1"
    });

    const queryParams = {
        TableName: "PizzaFlavours",
        KeyConditionExpression: "#tp = :flavourType and flavour = :flavour",
        ExpressionAttributeNames: {
            "#tp": "type"
        },
        ExpressionAttributeValues: {
            ":flavourType": {
                "S": type
            },
            ":flavour": {
                "S": flavour
            }
        },

    };

    return new Promise((resolve, reject) => {
        dynamoDB.query(queryParams, function (err, data) {
            if (err) return reject(err);

            if (data.Items.length <= 0) return resolve();

            console.log(JSON.stringify(data.Items[0]))
            return resolve(parseFloat(data.Items[0].price.N));
        })

    });
};

exports.handler = async (event) => {
    console.log(event);

    const { slots, confirmationStatus } = event.currentIntent;
    if (confirmationStatus !== 'None') return createDelegateResponse(slots);

    if (!slots.flavour) return createInvalidSlotResponse(slots, 'flavour', 'What is the pizza flavour that you want?');
    const flavourPrice = await getFlavourPrice('pizza', slots.flavour);
    if (flavourPrice === undefined) return createInvalidSlotResponse(slots, 'flavour', 'This pizza flavour is not available. Can you choose another one? :-)');

    if (!slots.crust) return createInvalidSlotResponse(slots, 'crust', 'What is the crust flavour?');
    const crustPrice = await getFlavourPrice('crust', slots.crust);
    if (crustPrice === undefined) return createInvalidSlotResponse(slots, 'crust', 'This crust flavour is not available. Can you choose another one? :-)');

    if (!slots.address) return createInvalidSlotResponse(slots, 'address', 'What is the delivery address?');

    return createConfirmationResponse(slots, flavourPrice + crustPrice);
};

O handler acima simplesmente valida os valores de cada um dos slots enviados pelo Lex, tentando obter o preço no DynamoDB através da função getFlavourPrice. Caso o valor do slot não tenha sido informado ou não exista, o mesmo será solicitado ao usuário através da mensagem de resposta criada em createInvalidSlotResponse.

Um ponto importante a ser observado é o armazenamento do totalPrice. Como não existe um slot para ele, armazenamos em SessionAttributes. Estes atributos permitem trafegar informações de contexto durante toda a interação com o Lex, inclusive entre intents diferentes. Eles estão disponíveis enquanto a sessão for válida, baseado no session timeout configurado na criação do bot.

Ao final, quando todos os slots estiverem preenchidos, enviamos uma mensagem ao usuário solicitando a confirmação do intent, informando o preço total (ver createConfirmationResponse). Como a resposta para está mensagem ainda é do tipo DialogCodeHook, ela volta para esta Lambda. Neste caso, apenas delegamos para o Lex na função createDelegateResponse, para deixá-lo seguir o fluxo. Caso a resposta do usuário seja negativa (“no“), ele resolverá a mensagem configurada no console (imagem abaixo), cancelando o intent. E em caso positivo, o fluxo seguirá para o fullfilment, onde será invocada a próxima lambda.

Confirmation prompt

Lambda para finalização

Para fins de demonstração, a Lambda utilizada em fullfilment é bem simples. Ela irá salvar o pedido do usuário e finalizar o intent.

const aws = require("aws-sdk");
const uuid = require('uuid/v4');

const saveOrder = (order) => {
    const dynamoDB = new aws.DynamoDB.DocumentClient({
        apiVersion: "2012-08-10",
        region: "us-east-1"
    });

    const params = {
        TableName: 'PizzaOrders',
        Item: order
    };

    return dynamoDB.put(params).promise();
};

const createOrder = (slots, sessionAttributes) => {

    return {
        orderId: uuid(),
        ...slots,
        total: sessionAttributes.totalPrice
    };
};

const createCloseResponse = () => {
    return {
        dialogAction: {
             type: "Close",
             fulfillmentState: "Fulfilled",
            message: {
                contentType: "PlainText",
                content: "Your order has been placed. Thank you! :-D"
            },
            responseCard: {
                version: 1,
                contentType: "application/vnd.amazonaws.card.generic",
                genericAttachments: [
                    {
                        title: "Your order has been placed!",
                        subTitle: "Do you need anything else?",
                        imageUrl: "https://i.ibb.co/D5c7xSB/Resized-pizza.png",
                        buttons: [
                            {
                                text: "Order another pizza",
                                value: "I want a pizza"
                            }
                        ]
                    }
                ]
            }
        }
    }
};

exports.handler = async (event) => {
    console.log(event);
    const { slots } = event.currentIntent;

    const order = createOrder(slots, event.sessionAttributes);
    await saveOrder(order);

    return createCloseResponse();
};

O primeiro passo é salvarmos a ordem utilizando as informações dos slots obtidos do usuário, e o totalPrice armazenado como um atributo de sessão (SessionAttributes). Por fim, respondemos ao Lex com uma mensagem tipo Close, informando que o intent foi finalizado com sucesso (fulfillmentState: Fulfilled).

Para utilizarmos a Lambda no Lex, precisamos selecioná-la em Fullfilment.

Fullfilment lambda

Integração com o API Gateway

Como o Lex não possui uma integração direta com o API Gateway, precisamos de uma Lambda que faça o meio campo entre os dois. Neste cenário, ela irá receber as mensagens da API Gateway, invocar o Lex passando os parâmetros necessários, e devolver a resposta do Lex para o usuário.



const aws = require('aws-sdk');

const proxyResponse = (status, message) => {
    console.log(message);

    return {
        statusCode: status,
        body: JSON.stringify(message)
    };
};

exports.handler = async (event) => {
    console.log(event);

    const payload = JSON.parse(event.body);
    const lex = new aws.LexRuntime({
        region: "us-east-1"
    });

    const params = {
        botAlias: '$LATEST',
        botName: 'PizzaBot',
        inputText: payload.message,
        userId: payload.userId,
        sessionAttributes: payload.sessionAttributes || {}
    };

    try {
        var lexResponse = await lex.postText(params).promise();
        return proxyResponse(200, lexResponse);
    } catch (e) {
        console.log(e);
        return proxyResponse(400, 'Ops. Houston, we have a problem! :-(');
    }
};

O Chatbot em ação

Agora que temos todos os artefatos necessários, vamos simular o pedido de uma pizza.

Na primeira requisição enviaremos a utterance (attributo message), para iniciar o intent, e o userId, que será usado para identificar a sessão. Na resposta do Lex, podemos identificar que ele iniciou o intent OrderAPizza, que o slot com o sabor da pizza já foi identificado, a mensagem solicitando o preenchimento do próximo slot, o slot que será preenchido, e o estado do diálogo (ElicitSlot).

Iniciando o intent

Enviando novamente a mensagem informando o sabor da borda, ele retorna uma resposta similar a resposta acima, solicitando o último slot.

Ao enviarmos a resposta do último slot, ele irá solicitar a confirmação do pedido (ConfirmIntent), incluindo o seu valor total como um atributo de sessão. Na resposta também está incluso um card , para facilitar a interação com o usuário.

Se informarmos no, o Lex irá cancelar o pedido. Se informarmos yes, devemos enviar junto o atributo de sessão, para que a lambda vinculada a fullfilment obtenha o preço (isso é adequado em caráter de exemplo, mas esse campo é passível de manipulação, então, em produção, devemos usar estratégias diferentes) e crie o pedido.

No = Cancelamento do pedido
Yes = Confirmação do pedido

Para garantir que o pedido tenha sido criado, podemos conferir o DynamoDB.

Conclusão

Por fim, as possibilidades com o Amazon Lex são infinitas. Através da integração com Lambda, podemos construir basicamente qualquer tipo de serviço que exija interações com o usuário.

Um grande abraço e boa semana.


Deixe uma resposta