UNPKG

botnaut

Version:

Facebook Messenger Chatbot Framework

451 lines (391 loc) 14 kB
/* * @author David Menger */ 'use strict'; const assert = require('assert'); const { WingbotModel } = require('./wingbot'); const Router = require('./Router'); const SERVICE_URL = 'https://model.wingbot.ai'; const DEFAULT_PREFIX = 'default'; /** * Mark request as handled - usefull for AI analytics * * @param {boolean} [aiHandled] true by default * @returns {Request} * @example * bot.use('some other query', (req, res) => { * req.markAsHandled(); * }); */ function markAsHandled (aiHandled = true) { Object.assign(this, { aiHandled }); return this; } /** * @class Ai */ class Ai { constructor () { this._keyworders = new Map(); /** * Upper threshold - for match method and for navigate method * * @type {number} */ this.confidence = 0.94; /** * Lower threshold - for navigate and makeSure methods * * @type {number} */ this.threshold = 0.6; /** * The logger (console by default) * * @type {Object} */ this.logger = console; /** * The prefix translator - for request-specific prefixes * * @param {string} prefix * @param {Request} req */ this.prefixTranslator = (prefix, req) => prefix; // eslint-disable-line this._mockIntent = null; } /** * Usefull method for testing AI routes * * @param {string} [intent] intent name * @param {number} [confidence] the confidence of the top intent * @returns {this} * @example * const { Tester, ai, Route } = require('bontaut'); * * const bot = new Route(); * * bot.use(['intentAction', ai.match('intentName')], (req, res) => { * res.text('PASSED'); * }); * * describe('bot', function () { * it('should work', function () { * ai.mockIntent('intentName'); * * const t = new Tester(bot); * * return t.text('Any text') * .then(() => { * t.actionPassed('intentAction'); * * t.any() * .contains('PASSED'); * }) * }); * }); */ mockIntent (intent = null, confidence = null) { if (intent === null) { this._mockIntent = null; } else { this._mockIntent = { intent, confidence }; } return this; } /** * When user confirms their intent, onIntentConfirmed handler will be called. * To create meta data from recognized request use getMeta handler. * Its useful for updating training data for AI * * @param {function} onIntentConfirmed - handler, which will be called when intent is confirmed * @param {function} getMeta - handler, which will be called when intent is confirmed * @returns {function} * @example * const { Router, ai } = require('botnaut'); * * bot.use(ai.onConfirmMiddleware((senderId, intent, text, timestamp, meta) => { * // log this information * }, (req) => { * // create and return meta data object * })); * * bot.use(ai.makeSure(['intent1', 'intent2']), (req, res) => { * console.log(req.confidences); // { intent1: 0.8604, intent2: undefined } * * res.text('What you mean?', res.ensures({ * intent1: 'Intent one?', * intent2: 'Intent two?', * anyOther: 'Niether' * })); * }); */ onConfirmMiddleware (onIntentConfirmed, getMeta = null) { return (req) => { if (getMeta !== null) { Object.assign(req, { _aiGetMetaFn: getMeta }); } if (!req.isQuickReply()) { return Router.CONTINUE; } const { _aiIntentMatched, _aiFromText, _aiTs, _aiMeta } = req.action(true); if (_aiIntentMatched) { onIntentConfirmed(req.senderId, _aiIntentMatched, _aiFromText, _aiTs, _aiMeta ); } return Router.CONTINUE; }; } /** * Registers Wingbot AI model * * @param {string} model - model name * @param {Object} options - the configuration * @param {number} [options.cacheSize] - remember number of caches * @param {number} [options.matches] - ask AI for number of matches * @param {string} prefix - model prefix * @returns {WingbotModel} * @memberOf Ai */ register (model, options = {}, prefix = 'default', Model = WingbotModel) { const opts = Object.assign({ serviceUrl: SERVICE_URL, model }, options); const keyworder = new Model(opts, this.logger); this._keyworders.set(prefix, keyworder); return keyworder; } /** * Returns matching middleware * * @param {string|Array} intent * @param {number} [confidence] * @param {string} [prefix] * @returns {function} - the middleware * @memberOf Ai * @example * const { Router, ai } = require('botnaut'); * * ai.register('app-model'); * * bot.use(ai.match('intent1'), (req, res) => { * console.log(req.confidences); // { intent1: 0.9604 } * * res.text('Oh, intent 1 :)'); * }); */ match (intent, confidence = null, prefix = DEFAULT_PREFIX) { const intents = Array.isArray(intent) ? intent : [intent]; return this._middlewareFactory(prefix, intents, null, confidence || true); } /** * Create AI middleware, which resolves multiple replies * and **makes postback, when it's confident** * Confidence should be between `threshold` and `confidence` to proceed * to next resolver * * @param {Array|Object} knownIntents - list or map of accepted intents * @param {number} [threshold] - lower threshold * @param {number} [confidence] - upper threshold for confidence * @param {string} [prefix] - model name * @returns {function} - the middleware * @memberOf Ai * @example * const { Router, ai } = require('botnaut'); * * bot.use(ai.navigate(['intent1', 'intent2']), (req, res) => { * console.log(req.confidences); // { intent1: 0.8604, intent2: undefined } * * res.text('What you mean?', res.ensures({ * intent1: 'Intent one?', * intent2: 'Intent two?', * anyOther: 'Niether' * })); * }); */ navigate ( knownIntents, threshold = null, confidence = null, prefix = DEFAULT_PREFIX) { const filterFn = tag => (confidence || this.confidence) > tag.score && tag.score >= (threshold || this.threshold); return this._middlewareFactory(prefix, knownIntents, filterFn, confidence || true); } /** * Create AI middleware, which resolves multiple replies. * Confidence should be between `threshold` and `confidence` to proceed * to next resolver * * @param {Array|Object} knownIntents - list or map of accepted intents * @param {number} [threshold] - lower threshold * @param {number} [confidence] - upper threshold for confidence * @param {string} prefix - model name * @returns {function} - the middleware * @memberOf Ai * @example * const { Router, ai } = require('botnaut'); * * bot.use(ai.makeSure(['intent1', 'intent2']), (req, res) => { * console.log(req.confidences); // { intent1: 0.8604, intent2: undefined } * * res.text('What you mean?', res.ensures({ * intent1: 'Intent one?', * intent2: 'Intent two?', * anyOther: 'Niether' * })); * }); */ makeSure ( knownIntents, threshold = null, confidence = null, prefix = DEFAULT_PREFIX) { const filterFn = tag => (confidence || this.confidence) > tag.score && tag.score >= (threshold || this.threshold); return this._middlewareFactory(prefix, knownIntents, filterFn); } _queryModel (prefix, req) { if (this._mockIntent !== null) { return Promise.resolve([{ tag: this._mockIntent.intent, score: this._mockIntent.confidence || this.confidence }]); } else if (req.data.intent) { return Promise.resolve([{ tag: req.data.intent, score: this.confidence }]); } const prefixForRequest = this.prefixTranslator(prefix, req); const model = this._keyworders.get(prefixForRequest); assert.ok(!!model, 'The model should be registered!'); return model.resolve(req.text(true).replace(/-/g, ' ')); } _setAiMetadata (req, firstTag = null, aiHandled = false) { if (aiHandled && !req.aiHandled) { Object.assign(req, { aiHandled: true }); } if (req.aiIntent) { // skip } else if (firstTag) { Object.assign(req, { aiIntent: firstTag.tag, aiIntentScore: firstTag.score, markAsHandled, aiHandled }); } else if (typeof req.aiIntent === 'undefined') { Object.assign(req, { aiIntent: null, aiIntentScore: null, markAsHandled, aiHandled }); } } _middlewareFactory (prefix, knownIntents, filterFn = null, postBackConfidence = null) { const acceptIntents = Array.isArray(knownIntents) ? knownIntents : Object.keys(knownIntents); const actionMap = Array.isArray(knownIntents) ? knownIntents.reduce((o, i) => Object.assign(o, { [i]: i }), {}) : knownIntents; return (req, res, postBack) => { this._setAiMetadata(req); if (!req.isText()) { return Router.BREAK; } return this._queryModel(prefix, req) .then((tags) => { this._setAiMetadata(req, tags[0]); if (tags.length === 0) { return Router.BREAK; } const firstTag = tags[0]; const useConfidence = postBackConfidence === true ? this.confidence : postBackConfidence; if (postBackConfidence !== null && acceptIntents.indexOf(firstTag.tag) !== -1 && firstTag.score >= useConfidence) { this._setAiMetadata(req, tags[0], true); if (filterFn !== null) { postBack(actionMap[firstTag.tag]); return Router.END; } Object.assign(req, { confidences: { [firstTag.tag]: firstTag.score } }); return Router.CONTINUE; } if (filterFn === null) { return Router.BREAK; } const matchedIntents = tags .filter(tag => acceptIntents.indexOf(tag.tag) !== -1 && filterFn(tag)); const confidences = matchedIntents .reduce((o, tag) => Object.assign(o, { [tag.tag]: tag.score }), {}); const dropActions = acceptIntents .filter(intent => typeof confidences[intent] === 'undefined') .map(intent => actionMap[intent]); if (matchedIntents.length === 0) { return Router.BREAK; } Object.assign(req, { confidences }); Object.assign(res, { ensures: this._createEnsuresMethod( dropActions, matchedIntents, actionMap, req ) }); return Router.CONTINUE; }); }; } _createEnsuresMethod (dropActions, matchedIntents, actionMap, req) { return function ensures (replies) { const ret = Object.assign({}, replies); dropActions.forEach((action) => { if (typeof ret[action] !== 'undefined') { delete ret[action]; } }); matchedIntents.forEach((intent) => { const action = actionMap[intent.tag]; let assign = null; if (typeof ret[action] === 'string') { assign = { title: ret[action] }; } else if (typeof ret[action] === 'object') { assign = ret[action]; } if (assign !== null) { let _aiMeta = null; if (req._aiGetMetaFn) { _aiMeta = req._aiGetMetaFn(req); } Object.assign(assign, { _aiIntentMatched: intent.tag, _aiTs: req.data.timestamp, _aiFromText: req.text(), _aiMeta }); Object.assign(ret, { [action]: assign }); } }); return ret; }; } } Ai.ai = new Ai(); module.exports = Ai;