watson-developer-cloud / botkit-middleware

A middleware to connect Watson Conversation Service to different chat channels using Botkit
https://www.npmjs.com/package/botkit-middleware-watson
Apache License 2.0
206 stars 254 forks source link

"app actions" vs. "client actions"? #143

Closed data-henrik closed 5 years ago

data-henrik commented 6 years ago

Watson Assistant supports client actions, indicating the app to perform actions. The examples for botkit-middleware seem to discuss a different kind of "app action".

Clarification and a sample for both would be helpful.

data-henrik commented 6 years ago

WA puts something like the following into its response object when a client action is encountered:

  "actions": [
    {
      "type": "client", 
      "name": "henriktest", 
      "parameters": {
        "input": {
          "text": "client test"
        }
      }, 
      "result_variable": "mydateOUT"
    }
  ], 

Ideally, botkit-middleware has a hook to react to this and is able to send back the result to WA.

data-henrik commented 6 years ago

Would it be possible to expose postMessage to be able to send back the action result? In addition, maybe a new processActions hook (before after) is needed to provide the necessary processing environment.

Naktibalda commented 6 years ago

Client actions is a new functionality, this documentation was written long time ago. You are welcome to implement and document it yourself in this middleware or wait for @germanattanasio to do it.

There is no need to export postMessage, there is a sendToWatson method which takes care of context management too.

data-henrik commented 6 years ago

@Naktibalda I have seen and worked with sendToWatson. My understanding is that it involves all the steps (before, after, etc.) again and it would introduce recursion. Basically, in the middle of processing sendToWatson, we call sendToWatson again.

mitchmason commented 6 years ago

I haven't used this in awhile now... but I think that if you want to use client side actions then instead you should use the Botkit .hears function.

data-henrik commented 6 years ago

https://console.bluemix.net/docs/services/conversation/dialog-actions.html#action-client-example

Watson Assistant introduced a "standardized" approach to specify server and client actions. Server actions utilize IBM Cloud Functions, client actions are signaled by WA and executed by the client app without interacting with the user. The result of that action is directly fed back into WA and then used for the actual response.

Naktibalda commented 6 years ago

@Naktibalda I have seen and worked with sendToWatson. My understanding is that it involves all the steps (before, after, etc.) again and it would introduce recursion. Basically, in the middle of processing sendToWatson, we call sendToWatson again.

That's how I used it.

pgoldweic commented 6 years ago

OK. A year ago this full documentation wasn't there, so I imagine that lacking that specificity, this middleware simply enabled each user to implement their own 'client actions' using the documentation under 'app actions'. This is what I did, using their example, to implement simple 'client' actions that did not use parameters. Unfortunately this requires every developer to implement their own mechanism, which is not ideal, of course. It would be nice to have it already built into this middleware. I think this is what you are asking for @data-henrik, correct? I'm curious about the issue you describe with sendToWatson though; I've used it as exemplified in this middleware's docs (under 'app actions') and did not see a problem though.

data-henrik commented 6 years ago

Correct, I am asking to have consistent documentation. If it already works without modification, the botkit documentation should refer to the WA way of implementing client actions.

If not, it would be great for botkit-middleware to support the WA client actions.

This tutorial uses server actions: https://console.bluemix.net/docs/tutorials/slack-chatbot-database-watson.html There could be a tutorial using botkit and client actions.

pgoldweic commented 6 years ago

It seems (and @Naktibalda appears to confirm this ) that it is not supporting this form of client actions yet. The only thing that it supports so far is using a mechanism exactly as described in the section 'Implementing app actions'. My understanding (and experience with it) is that you have to reproduce the 'processWatsonResponse' function as described there to have these simple actions (without parameters) work. This does in fact send the action results back to Watson in the manner described. However, in order to implement a more complex mechanism as explained in the documentation you pointed us to, somebody would have to code it first :-(. I agree that it would be great to have it.

germanattanasio commented 6 years ago

This is a feature that we will have to discuss @mitchmason. Let me know what you think

hendrul commented 5 years ago

Finally I have written this "after" wrapper, and it works very well.

var set = require("set-value");
var merge = require("deepmerge");

var ACTIONS_DIR = "../actions/";
var WORKSPACE_ID = process.env.ASSISTANT_WORKSPACE_ID;
// prettier-ignore
var ASSISTANT_SENDMESSAGE_URL =
  'https://gateway.watsonplatform.net/assistant/api/v1/workspaces/' + process.env.ASSISTANT_WORKSPACE_ID + '/message';

/**
 * Intercepts and process the actions on messages comming from watson.
 *
 * @param {Object} controller
 * @param {Object} watsonMiddleware
 */
function main(controller, watsonMiddleware) {
  var nextAfter = watsonMiddleware.after;
  function actionProcessor(bot, watsonData, cb) {
    var actionCalls = watsonData.actions;
    if (actionCalls && actionCalls.length > 0) {
      return processActions(actionCalls).then(function(actionResults) {
        // Merge action results on the payload, this because sendToWatson
        // only allows context deltas, but result variables could be set
        // on input or output fields either
        watsonData = merge(watsonData, actionResults, {
          arrayMerge: function(destinationArray, sourceArray, options) {
            return sourceArray;
          }
        });
        var watsonRequest = {
          workspace_id: WORKSPACE_ID,
          context: watsonData.context || {},
          input: {},
          nodes_visited_details: true
        };
        // prettier-ignore
        watsonMiddleware.conversation.message(
            watsonRequest, 
            function(err, watsonResponse) {
              if (err) throw err;
              actionProcessor(bot, watsonResponse, cb);
            }
          );
      });
    } else {
      nextAfter(bot, watsonData, cb);
    }
  }
  watsonMiddleware.after = actionProcessor;
}

/**
 * Execute action descriptors sequentially.
 * @param {Object[]} actionDescriptors  Action descriptors.
 * @param {Object} options Options
 * @param {string} options.stopOnError Stop execution of more actions after an error is thrown
 * @returns {object}} The results of action calls set on their respective path specified
 *          by "result_variable" attribute
 **/
function processActions(actionDescriptors, options = {}) {
  var stopOnError = options.stopOnError || true;
  var newPayload = {};
  var p = Promise.resolve();
  for (var i = 0; i < actionDescriptors.length; i++) {
    if (actionDescriptors[i].type === "client") {
      (function() {
        var actionCall = actionDescriptors[i];
        /* We pass environment through action "params" argument
         * so you can implement client actions in a compatible way
         * with cloud-function actions 
         */
        var actionParams = Object.assign(
          {},
          process.env,    
          actionCall.parameters
        );
        p = p.then(function() {
          var action = findAction(actionCall.name);
          // prettier-ignore
          return Promise.resolve(action(actionParams))
            .then(function(actionResult) {
              setResultValue(newPayload, actionCall.result_variable, actionResult);
            })
            .catch(function(err) {
              setResultValue(newPayload, actionCall.result_variable, { action_error: err.message });
              if(stopOnError) throw err;
            });
        });
      })();
    }
  }
  return p
    .then(function() {
      return newPayload;
    })
    .catch(function(err) {
      return newPayload;
    });
}

/**
 * Sets a value on a path on "payload". Follows the result_variable specs given on ()[]
 * @param {Object} payload  The watson payload.
 * @param {string} resultVariable The path on the payload where the action result belongs
 * @param {*} value The action result value to set
 **/
function setResultValue(payload, resultVariable, value) {
  var m = resultVariable.match(/^(\$)?(context|input|output)?(.*)/);
  resultVariable = (m[1] || !m[2] ? "context." : "") + (m[2] || "") + m[3];
  set(payload, resultVariable, value);
}

function findAction(name) {
  var normalizedPath = require("path").join(__dirname, ACTIONS_DIR, name);
  var action = require(normalizedPath);
  if (typeof action !== "function" && typeof action.main === "function") {
    action = action.main;
  }
  return action;
}

module.exports = main;
module.exports._processActions = processActions;
module.exports._findAction = findAction;
module.exports._setResultValue = setResultValue;
HARVS1789UK commented 5 years ago

@hendrul thanks for sharing the above, I am hopeful based on @data-henrik's thumbs up that it is a potential solution. I have read through it myself and it looks to cover all the bases, but I am new to Node.js, ES6 style JS, new to Botkit and new to Watson Assistant!

I therefore have a few questions for you to help me attempt to implement your interim fix/wrapper:

1 - In which file would I place this code (app.js)? 2 - Am I correct in thinking that I would need to create a sub-directory in my web root named acions and create 1 x new .js file for each of my Watson Assistant 'Dialog Actions' (e.g. ../actions/getSuitableBusStops.js)? 3 - Assuming # 2 is correct, what form would these take? Would it be:

module.exports = function(params) {
    // ...put my code here (params = the JSON params provided in WA 'action')...
}

4 - Are there any other steps I would have to implement to get it all to work other than the above?

@germanattanasio just as an FYI, massive +1 for some sort of listener for "watsonData contains action(s)" being built in functionality :-D

hendrul commented 5 years ago

In which file would I place this code (app.js)?

I'm using the starter kit for botkit, this code is a "skill", which consist of a function which accept the botkit controller as a parameter, I modified the original caller to pass the watson middleware as second argument. That's my particular way to wrap "after".

Am I correct in thinking that I would need to create a sub-directory in my web root named acions and create 1 x new .js file for each of my Watson Assistant 'Dialog Actions' (e.g. ../actions/getSuitableBusStops.js)?

Assuming # 2 is correct, what form would these take? Would it be:

Yes, thats right, here is an example action:

// actions/get-intents-description.js

var AssistantV1 = require("watson-developer-cloud/assistant/v1");

var cache;
function main(params) {
  var intentRegex = new RegExp(params["regex"] ? params["regex"] : ".*", "i");
  return new Promise(function(resolve, reject) {
    var currentTime = Date.now();
    if (cache && currentTime - cache.lastUpdate < 60 * 1000) {
      resolve(filterIntents(cache.data, intentRegex));
    } else {
      var conversation = new AssistantV1({
        username: params["ASSISTANT_USERNAME"],
        password: params["ASSISTANT_PASSWORD"],
        version: params["ASSISTANT_APIVERSION"],
        url: params["ASSISTANT_URL"]
      });
      conversation.listIntents(
        {
          workspace_id: params["ASSISTANT_WORKSPACE_ID"],
          export: true
        },
        function(err, response) {
          var descriptions = filterIntents(response.intents, intentRegex);
          cache = {
            lastUpdate: Date.now(),
            data: response.intents
          };
          resolve(descriptions);
        }
      );
    }
  });
}

function filterIntents(intents, regex) {
  return intents
    .filter(function(intent) {
      return (
        intent &&
        typeof intent.intent === "string" &&
        typeof intent.description === "string" &&
        intent.description.length > 0 &&
        !!intent.intent.match(regex)
      );
    })
    .map(function(intent) {
      return intent.description;
    });
}

module.exports = main;

My intent is to have client functions looks as close as possible to cloud-functions. Note: environment variables can be accessed from the argument params.

You call this action in a watson node writting a json like this one:

{
  "output": {
    "generic": [
      {
        "values": [],
        "response_type": "text",
        "selection_policy": "sequential"
      }
    ]
  },
  "actions": [
    {
      "name": "get-intent-descriptions",
      "type": "client",
      "parameters": {
        "regex": "^faq_"
      },
      "result_variable": "$faq_list"
    }
  ]
}

Are there any other steps I would have to implement to get it all to work other than the above?

As a user, wrapping "after" is the best choice I see to add this extension... but a contributor could add it built into sendToWatson or something more elaborated

germanattanasio commented 5 years ago

This issue is fairly old and there hasn't been much activity on it. Closing, but please re-open if it still occurs.