UNPKG

botium-core

Version:
1,094 lines (1,028 loc) 67.6 kB
const LogicHookUtils = require('./logichook/LogicHookUtils') const util = require('util') const crypto = require('crypto') const fs = require('fs') const path = require('path') const globby = require('globby') const _ = require('lodash') const randomize = require('randomatic') const promiseRetry = require('promise-retry') require('promise.allsettled').shim() const debug = require('debug')('botium-core-ScriptingProvider') const Constants = require('./Constants') const Capabilities = require('../Capabilities') const Defaults = require('../Defaults') const { Convo, ConvoStep } = require('./Convo') const ScriptingMemory = require('./ScriptingMemory') const { BotiumError, botiumErrorFromList, botiumErrorFromErr } = require('./BotiumError') const RetryHelper = require('../helpers/RetryHelper') const { getMatchFunction } = require('./MatchFunctions') const precompilers = require('./precompilers') const { calculateWer, toPercent } = require('./helper') const globPattern = '**/+(*.convo.txt|*.utterances.txt|*.pconvo.txt|*.scriptingmemory.txt|*.xlsx|*.xlsm|*.convo.csv|*.pconvo.csv|*.utterances.csv|*.yaml|*.yml|*.json|*.md|*.markdown)' const skipPattern = /^skip[.\-_]/i const p = (retryHelper, fn) => { const promise = () => new Promise((resolve, reject) => { try { resolve(fn()) } catch (err) { reject(err) } }) if (retryHelper) { return promiseRetry((retry, number) => { return promise().catch(err => { if (retryHelper.shouldRetry(err)) { debug(`Asserter trial #${number} failed, retry activated`) retry(err) } else { throw err } }) }, retryHelper.retrySettings) } else { return promise() } } const pnot = (retryHelper, fn, errTemplate) => { const promise = () => new Promise(async (resolve, reject) => { // eslint-disable-line no-async-promise-executor try { await fn() reject(errTemplate) } catch (err) { resolve() } }) if (retryHelper) { return promiseRetry((retry, number) => { return promise().catch(() => { if (retryHelper.shouldRetry(errTemplate)) { debug(`Asserter trial #${number} failed, !retry activated`) retry(errTemplate) } else { throw errTemplate } }) }, retryHelper.retrySettings) } else { return promise() } } module.exports = class ScriptingProvider { constructor (caps) { this.caps = caps || _.cloneDeep(Defaults.Capabilities) this.compilers = {} this.convos = [] this.utterances = {} this.matchFn = null this.asserters = {} this.globalAsserter = {} this.logicHooks = {} this.globalLogicHook = {} this.userInputs = {} this.partialConvos = {} this.scriptingMemories = [] this.scriptingEvents = { onConvoBegin: ({ convo, convoStep, scriptingMemory, ...rest }) => { return this._createLogicHookPromises({ hookType: 'onConvoBegin', logicHooks: (convo.beginLogicHook || []), convo, convoStep, scriptingMemory, ...rest }) }, onConvoEnd: ({ convo, convoStep, scriptingMemory, ...rest }) => { return this._createLogicHookPromises({ hookType: 'onConvoEnd', logicHooks: (convo.endLogicHook || []), convo, convoStep, scriptingMemory, ...rest }) }, onMeStart: ({ convo, convoStep, scriptingMemory, ...rest }) => { return this._createLogicHookPromises({ hookType: 'onMeStart', logicHooks: (convoStep.logicHooks || []), convo, convoStep, scriptingMemory, ...rest }) }, onMePrepare: ({ convo, convoStep, scriptingMemory, ...rest }) => { return this._createLogicHookPromises({ hookType: 'onMePrepare', logicHooks: (convoStep.logicHooks || []), convo, convoStep, scriptingMemory, ...rest }) }, onMeEnd: ({ convo, convoStep, scriptingMemory, ...rest }) => { return this._createLogicHookPromises({ hookType: 'onMeEnd', logicHooks: (convoStep.logicHooks || []), convo, convoStep, scriptingMemory, ...rest }) }, onBotStart: ({ convo, convoStep, scriptingMemory, ...rest }) => { return this._createLogicHookPromises({ hookType: 'onBotStart', logicHooks: (convoStep.logicHooks || []), convo, convoStep, scriptingMemory, ...rest }) }, onBotPrepare: ({ convo, convoStep, scriptingMemory, ...rest }) => { return this._createLogicHookPromises({ hookType: 'onBotPrepare', logicHooks: (convoStep.logicHooks || []), convo, convoStep, scriptingMemory, ...rest }) }, onBotEnd: ({ convo, convoStep, scriptingMemory, ...rest }) => { return this._createLogicHookPromises({ hookType: 'onBotEnd', logicHooks: (convoStep.logicHooks || []), convo, convoStep, scriptingMemory, ...rest }) }, assertConvoBegin: ({ convo, convoStep, scriptingMemory, ...rest }) => { return this._createAsserterPromises({ asserterType: 'assertConvoBegin', asserters: (convo.beginAsserter || []), convo, convoStep, scriptingMemory, ...rest }) }, assertConvoStep: ({ convo, convoStep, scriptingMemory, ...rest }) => { return this._createAsserterPromises({ asserterType: 'assertConvoStep', asserters: (convoStep.asserters || []), convo, convoStep, scriptingMemory, ...rest }) }, assertConvoEnd: ({ convo, convoStep, scriptingMemory, ...rest }) => { return this._createAsserterPromises({ asserterType: 'assertConvoEnd', asserters: (convo.endAsserter || []), convo, convoStep, scriptingMemory, ...rest }) }, setUserInput: ({ convo, convoStep, scriptingMemory, ...rest }) => { return this._createUserInputPromises({ convo, convoStep, scriptingMemory, ...rest }) }, resolveUtterance: ({ utterance, resolveEmptyIfUnknown }) => { return this._resolveUtterance({ utterance, resolveEmptyIfUnknown }) }, assertBotResponse: (botresponse, tomatch, stepTag, meMsg) => { if (!_.isArray(tomatch)) { tomatch = [tomatch] } debug(`assertBotResponse ${stepTag} ${meMsg ? `(${meMsg}) ` : ''}BOT: ${botresponse} = ${tomatch} ...`) const found = _.find(tomatch, (utt) => this.matchFn(botresponse, utt, this.caps[Capabilities.SCRIPTING_MATCHING_MODE_ARGS])) const asserterType = this.caps[Capabilities.SCRIPTING_MATCHING_MODE] === 'wer' ? 'Word Error Rate Asserter' : 'Text Match Asserter' if (_.isNil(found)) { if (this.caps[Capabilities.SCRIPTING_MATCHING_MODE] === 'wer') { const wer = calculateWer(botresponse, tomatch[0]) const werArgs = this.caps[Capabilities.SCRIPTING_MATCHING_MODE_ARGS] const threshold = ([',', '.'].find(p => `${werArgs[0]}`.includes(p)) ? parseFloat(werArgs[0]) : parseInt(werArgs[0]) / 100) const message = `${stepTag}: Word Error Rate (${toPercent(wer)}) higher than accepted (${toPercent(threshold)})` throw new BotiumError( message, { type: 'asserter', source: asserterType, params: { matchingMode: this.caps[Capabilities.SCRIPTING_MATCHING_MODE], args: this.caps[Capabilities.SCRIPTING_MATCHING_MODE_ARGS] || null }, context: { stepTag }, cause: { expected: `<=${toPercent(threshold)} (${tomatch})`, actual: `${toPercent(wer)} (${botresponse})` } } ) } else { let message = `${stepTag}: Bot response ` message += meMsg ? `(on ${meMsg}) ` : '' message += botresponse ? ('"' + botresponse + '"') : '<no response>' message += ' expected to match ' message += tomatch && tomatch.length > 1 ? 'one of ' : '' message += `${tomatch.map(e => e ? '"' + e + '"' : '<any response>').join(', ')}` throw new BotiumError( message, { type: 'asserter', source: asserterType, params: { matchingMode: this.caps[Capabilities.SCRIPTING_MATCHING_MODE], args: this.caps[Capabilities.SCRIPTING_MATCHING_MODE_ARGS] || null }, context: { stepTag }, cause: { expected: tomatch, actual: botresponse, matchingMode: this.caps[Capabilities.SCRIPTING_MATCHING_MODE], args: this.caps[Capabilities.SCRIPTING_MATCHING_MODE_ARGS] || null } } ) } } }, assertBotNotResponse: (botresponse, nottomatch, stepTag, meMsg) => { if (!_.isArray(nottomatch)) { nottomatch = [nottomatch] } debug(`assertBotNotResponse ${stepTag} ${meMsg ? `(${meMsg}) ` : ''}BOT: ${botresponse} != ${nottomatch} ...`) const found = _.find(nottomatch, (utt) => this.matchFn(botresponse, utt, this.caps[Capabilities.SCRIPTING_MATCHING_MODE_ARGS])) const asserterType = this.caps[Capabilities.SCRIPTING_MATCHING_MODE] === 'wer' ? 'Word Error Rate Asserter' : 'Text Match Asserter' if (!_.isNil(found)) { if (this.caps[Capabilities.SCRIPTING_MATCHING_MODE] === 'wer') { const wer = calculateWer(botresponse, nottomatch[0]) const werArgs = this.caps[Capabilities.SCRIPTING_MATCHING_MODE_ARGS] const threshold = ([',', '.'].find(p => `${werArgs[0]}`.includes(p)) ? parseFloat(werArgs[0]) : parseInt(werArgs[0]) / 100) const message = `${stepTag}: Word Error Rate (${toPercent(wer)}) lower than accepted (${toPercent(threshold)})` throw new BotiumError( message, { type: 'asserter', source: asserterType, params: { matchingMode: this.caps[Capabilities.SCRIPTING_MATCHING_MODE], args: this.caps[Capabilities.SCRIPTING_MATCHING_MODE_ARGS] || null }, context: { stepTag }, cause: { expected: `>=${toPercent(threshold)} (${nottomatch})`, actual: `${toPercent(wer)} (${botresponse})` } } ) } else { let message = `${stepTag}: Bot response ` message += meMsg ? `(on ${meMsg}) ` : '' message += botresponse ? ('"' + botresponse + '"') : '<no response>' message += ' expected NOT to match ' message += nottomatch && nottomatch.length > 1 ? 'one of ' : '' message += `${nottomatch.map(e => e ? '"' + e + '"' : '<any response>').join(', ')}` throw new BotiumError( message, { type: 'asserter', source: asserterType, params: { matchingMode: this.caps[Capabilities.SCRIPTING_MATCHING_MODE], args: this.caps[Capabilities.SCRIPTING_MATCHING_MODE_ARGS] || null }, context: { stepTag }, cause: { not: true, expected: nottomatch, actual: botresponse, matchingMode: this.caps[Capabilities.SCRIPTING_MATCHING_MODE], args: this.caps[Capabilities.SCRIPTING_MATCHING_MODE_ARGS] || null } } ) } } }, fail: null } this.retryHelperAsserter = new RetryHelper(this.caps, 'ASSERTER') this.retryHelperLogicHook = new RetryHelper(this.caps, 'LOGICHOOK') this.retryHelperUserInput = new RetryHelper(this.caps, 'USERINPUT') } _createAsserterPromises ({ asserterType, asserters, convo, convoStep, scriptingMemory, container, ...rest }) { if (!this._isValidAsserterType(asserterType)) { throw Error(`Unknown asserterType ${asserterType}`) } const mapNot = { assertConvoBegin: 'assertNotConvoBegin', assertConvoStep: 'assertNotConvoStep', assertConvoEnd: 'assertNotConvoEnd' } const callAsserter = (asserterSpec, asserter, params) => { if (asserterSpec.not) { const notAsserterType = mapNot[asserterType] if (asserter[notAsserterType]) { return p(this.retryHelperAsserter, () => asserter[notAsserterType](params)) } else { return pnot(this.retryHelperAsserter, () => asserter[asserterType](params), new BotiumError( `${convoStep.stepTag}: Expected asserter ${asserter.name || asserterSpec.name} with args "${params.args}" to fail`, { type: 'asserter', source: asserter.name || asserterSpec.name, params: { args: params.args }, cause: { not: true, expected: 'failed', actual: 'not failed' } } ) ) } } else { return p(this.retryHelperAsserter, () => asserter[asserterType](params)) } } const convoAsserter = asserters .filter(a => this.asserters[a.name][asserterType]) .map(a => callAsserter(a, this.asserters[a.name], { convo, convoStep, scriptingMemory, container, args: ScriptingMemory.applyToArgs(a.args, scriptingMemory, container.caps, rest.botMsg), isGlobal: false, ...rest })) const globalAsserter = Object.values(this.globalAsserter) .filter(a => a[asserterType]) .map(a => p(this.retryHelperAsserter, () => a[asserterType]({ convo, convoStep, scriptingMemory, container, args: [], isGlobal: true, ...rest }))) const allPromises = [...convoAsserter, ...globalAsserter] if (this.caps[Capabilities.SCRIPTING_ENABLE_MULTIPLE_ASSERT_ERRORS]) { return Promise.allSettled(allPromises).then((results) => { const rejected = results.filter(result => result.status === 'rejected').map(result => result.reason) if (rejected.length) { throw botiumErrorFromList(rejected, {}) } return results.filter(result => result.status === 'fulfilled').map(result => result.value) }) } if (allPromises.length > 0) return Promise.all(allPromises).then(() => true) return Promise.resolve(false) } _createLogicHookPromises ({ hookType, logicHooks, convo, convoStep, scriptingMemory, container, ...rest }) { if (hookType !== 'onMeStart' && hookType !== 'onMePrepare' && hookType !== 'onMeEnd' && hookType !== 'onBotStart' && hookType !== 'onBotPrepare' && hookType !== 'onBotEnd' && hookType !== 'onConvoBegin' && hookType !== 'onConvoEnd' ) { throw Error(`Unknown hookType ${hookType}`) } const convoStepPromises = (logicHooks || []) .filter(l => this.logicHooks[l.name][hookType]) .map(l => p(this.retryHelperLogicHook, () => this.logicHooks[l.name][hookType]({ convo, convoStep, scriptingMemory, container, args: ScriptingMemory.applyToArgs(l.args, scriptingMemory, container.caps, rest.botMsg), isGlobal: false, ...rest }))) const globalPromises = Object.values(this.globalLogicHook) .filter(l => l[hookType]) .map(l => p(this.retryHelperLogicHook, () => l[hookType]({ convo, convoStep, scriptingMemory, container, args: [], isGlobal: true, ...rest }))) const allPromises = [...convoStepPromises, ...globalPromises] if (allPromises.length > 0) return Promise.all(allPromises).then(() => true) return Promise.resolve(false) } _createUserInputPromises ({ convo, convoStep, scriptingMemory, container, ...rest }) { const convoStepPromises = (convoStep.userInputs || []) .filter(ui => this.userInputs[ui.name]) .map(ui => p(this.retryHelperUserInput, () => this.userInputs[ui.name].setUserInput({ convo, convoStep, scriptingMemory, container, args: ScriptingMemory.applyToArgs(ui.args, scriptingMemory, container.caps, rest.meMsg), ...rest }))) if (convoStepPromises.length > 0) return Promise.all(convoStepPromises).then(() => true) return Promise.resolve(false) } _isValidAsserterType (asserterType) { return ['assertConvoBegin', 'assertConvoStep', 'assertConvoEnd'].some(t => asserterType === t) } _resolveUtterance ({ utterance, resolveEmptyIfUnknown = false }) { if (_.isString(utterance)) { if (this.utterances[utterance]) { return this.utterances[utterance].utterances } else { const parts = utterance.split(' ') if (this.utterances[parts[0]]) { const uttArgs = parts.slice(1) return this.utterances[parts[0]].utterances.map(utt => util.format(utt, ...uttArgs)) } } } if (resolveEmptyIfUnknown) return null else return [utterance] } _buildScriptContext () { return { AddConvos: this.AddConvos.bind(this), AddUtterances: this.AddUtterances.bind(this), AddPartialConvos: this.AddPartialConvos.bind(this), AddScriptingMemories: this.AddScriptingMemories.bind(this), Match: this.Match.bind(this), IsAsserterValid: this.IsAsserterValid.bind(this), IsLogicHookValid: this.IsLogicHookValid.bind(this), IsUserInputValid: this.IsUserInputValid.bind(this), GetPartialConvos: this.GetPartialConvos.bind(this), scriptingEvents: { assertConvoBegin: this.scriptingEvents.assertConvoBegin.bind(this), assertConvoStep: this.scriptingEvents.assertConvoStep.bind(this), assertConvoEnd: this.scriptingEvents.assertConvoEnd.bind(this), resolveUtterance: this.scriptingEvents.resolveUtterance.bind(this), assertBotResponse: this.scriptingEvents.assertBotResponse.bind(this), assertBotNotResponse: this.scriptingEvents.assertBotNotResponse.bind(this), onConvoBegin: this.scriptingEvents.onConvoBegin.bind(this), onConvoEnd: this.scriptingEvents.onConvoEnd.bind(this), onMeStart: this.scriptingEvents.onMeStart.bind(this), onMePrepare: this.scriptingEvents.onMePrepare.bind(this), onMeEnd: this.scriptingEvents.onMeEnd.bind(this), onBotStart: this.scriptingEvents.onBotStart.bind(this), onBotPrepare: this.scriptingEvents.onBotPrepare.bind(this), onBotEnd: this.scriptingEvents.onBotEnd.bind(this), setUserInput: this.scriptingEvents.setUserInput.bind(this), fail: this.scriptingEvents.fail && this.scriptingEvents.fail.bind(this) } } } Build () { const CompilerXlsx = require('./CompilerXlsx') this.compilers[Constants.SCRIPTING_FORMAT_XSLX] = new CompilerXlsx(this._buildScriptContext(), this.caps) this.compilers[Constants.SCRIPTING_FORMAT_XSLX].Validate() const CompilerTxt = require('./CompilerTxt') this.compilers[Constants.SCRIPTING_FORMAT_TXT] = new CompilerTxt(this._buildScriptContext(), this.caps) this.compilers[Constants.SCRIPTING_FORMAT_TXT].Validate() const CompilerCsv = require('./CompilerCsv') this.compilers[Constants.SCRIPTING_FORMAT_CSV] = new CompilerCsv(this._buildScriptContext(), this.caps) this.compilers[Constants.SCRIPTING_FORMAT_CSV].Validate() const CompilerYaml = require('./CompilerYaml') this.compilers[Constants.SCRIPTING_FORMAT_YAML] = new CompilerYaml(this._buildScriptContext(), this.caps) this.compilers[Constants.SCRIPTING_FORMAT_YAML].Validate() const CompilerJson = require('./CompilerJson') this.compilers[Constants.SCRIPTING_FORMAT_JSON] = new CompilerJson(this._buildScriptContext(), this.caps) this.compilers[Constants.SCRIPTING_FORMAT_JSON].Validate() const CompilerMarkdown = require('./CompilerMarkdown') this.compilers[Constants.SCRIPTING_FORMAT_MARKDOWN] = new CompilerMarkdown(this._buildScriptContext(), this.caps) this.compilers[Constants.SCRIPTING_FORMAT_MARKDOWN].Validate() this.matchFn = getMatchFunction(this.caps[Capabilities.SCRIPTING_MATCHING_MODE]) const logicHookUtils = new LogicHookUtils({ buildScriptContext: this._buildScriptContext(), caps: this.caps }) this.asserters = logicHookUtils.asserters this.globalAsserter = logicHookUtils.getGlobalAsserter() this.logicHooks = logicHookUtils.logicHooks this.globalLogicHook = logicHookUtils.getGlobalLogicHook() this.userInputs = logicHookUtils.userInputs } IsAsserterValid (name) { return this.asserters[name] || false } IsLogicHookValid (name) { return this.logicHooks[name] || false } IsUserInputValid (name) { return this.userInputs[name] || false } Match (botresponse, utterance) { return this.matchFn(botresponse, utterance, this.caps[Capabilities.SCRIPTING_MATCHING_MODE_ARGS]) } Compile (scriptBuffer, scriptFormat, scriptType) { const compiler = this.GetCompiler(scriptFormat) return compiler.Compile(scriptBuffer, scriptType) } Decompile (convos, scriptFormat) { const compiler = this.GetCompiler(scriptFormat) return compiler.Decompile(convos) } GetCompiler (scriptFormat) { const result = this.compilers[scriptFormat] if (result) return result throw new Error(`No compiler found for scriptFormat ${scriptFormat}`) } ReadBotiumFilesFromDirectory (convoDir, globFilter) { const filelist = globby.sync(globPattern, { cwd: convoDir, gitignore: true }) if (globFilter) { const filelistGlobbed = globby.sync(globFilter, { cwd: convoDir, gitignore: true }) _.remove(filelist, (file) => filelistGlobbed.indexOf(file) < 0) } _.remove(filelist, (file) => { const isSkip = skipPattern.test(path.basename(file)) if (isSkip) debug(`ReadBotiumFilesFromDirectory - skipping file '${file}'`) return isSkip }) return filelist } ReadScriptsFromDirectory (convoDir, globFilter) { let filelist = [] const convoDirStats = fs.statSync(convoDir) if (convoDirStats.isFile()) { filelist = [path.basename(convoDir)] convoDir = path.dirname(convoDir) } else { filelist = this.ReadBotiumFilesFromDirectory(convoDir, globFilter) } debug(`ReadConvosFromDirectory(${convoDir}) found filenames: ${filelist}`) const dirConvos = [] const dirUtterances = [] const dirPartialConvos = [] const dirScriptingMemories = [] filelist.forEach((filename) => { const { convos, utterances, pconvos, scriptingMemories } = this.ReadScript(convoDir, filename) if (convos) dirConvos.push(...convos) if (utterances) dirUtterances.push(...utterances) if (pconvos) dirPartialConvos.push(...pconvos) if (scriptingMemories) dirScriptingMemories.push(...scriptingMemories) }) debug(`ReadConvosFromDirectory(${convoDir}) found convos:\n ${dirConvos.length ? dirConvos.join('\n') : 'none'}`) debug(`ReadConvosFromDirectory(${convoDir}) found utterances:\n ${dirUtterances.length ? _.map(dirUtterances, (u) => u).join('\n') : 'none'}`) debug(`ReadConvosFromDirectory(${convoDir}) found partial convos:\n ${dirPartialConvos.length ? dirPartialConvos.join('\n') : 'none'}`) debug(`ReadConvosFromDirectory(${convoDir}) scripting memories:\n ${dirScriptingMemories.length ? dirScriptingMemories.map((dirScriptingMemory) => util.inspect(dirScriptingMemory)).join('\n') : 'none'}`) return { convos: dirConvos, utterances: dirUtterances, pconvos: dirPartialConvos, scriptingMemories: dirScriptingMemories } } ReadScriptFromBuffer (scriptBuffer, scriptingFormat, scriptingTypes = null) { if (_.isString(scriptingTypes)) scriptingTypes = [scriptingTypes] if (_.isArray(scriptingTypes) && scriptingTypes.length === 0) scriptingTypes = null const result = { convos: [], utterances: [], pconvos: [], scriptingMemories: [] } if (!scriptingTypes || scriptingTypes.includes(Constants.SCRIPTING_TYPE_UTTERANCES)) { result.utterances = this.Compile(scriptBuffer, scriptingFormat, Constants.SCRIPTING_TYPE_UTTERANCES) } if (!scriptingTypes || scriptingTypes.includes(Constants.SCRIPTING_TYPE_PCONVO)) { result.pconvos = this.Compile(scriptBuffer, scriptingFormat, Constants.SCRIPTING_TYPE_PCONVO) } if (!scriptingTypes || scriptingTypes.includes(Constants.SCRIPTING_TYPE_CONVO)) { result.convos = this.Compile(scriptBuffer, scriptingFormat, Constants.SCRIPTING_TYPE_CONVO) } if (!scriptingTypes || scriptingTypes.includes(Constants.SCRIPTING_TYPE_SCRIPTING_MEMORY)) { result.scriptingMemories = this.Compile(scriptBuffer, scriptingFormat, Constants.SCRIPTING_TYPE_SCRIPTING_MEMORY) } return result } ReadScript (convoDir, filename) { let result = {} try { let scriptBuffer = fs.readFileSync(path.resolve(convoDir, filename)) const precompResponse = precompilers.execute(scriptBuffer, { convoDir, filename, caps: this.caps }) if (precompResponse) { scriptBuffer = precompResponse.scriptBuffer debug(`File ${filename} precompiled by ${precompResponse.precompiler}` + (precompResponse.filename ? ` and filename changed to ${precompResponse.filename}` : '') ) filename = precompResponse.filename || filename } if (filename.endsWith('.xlsx') || filename.endsWith('.xlsm')) { result = this.ReadScriptFromBuffer(scriptBuffer, Constants.SCRIPTING_FORMAT_XSLX, [Constants.SCRIPTING_TYPE_UTTERANCES, Constants.SCRIPTING_TYPE_PCONVO, Constants.SCRIPTING_TYPE_CONVO, Constants.SCRIPTING_TYPE_SCRIPTING_MEMORY]) } else if (filename.endsWith('.convo.txt')) { result = this.ReadScriptFromBuffer(scriptBuffer, Constants.SCRIPTING_FORMAT_TXT, Constants.SCRIPTING_TYPE_CONVO) } else if (filename.endsWith('.pconvo.txt')) { result = this.ReadScriptFromBuffer(scriptBuffer, Constants.SCRIPTING_FORMAT_TXT, Constants.SCRIPTING_TYPE_PCONVO) } else if (filename.endsWith('.utterances.txt')) { result = this.ReadScriptFromBuffer(scriptBuffer, Constants.SCRIPTING_FORMAT_TXT, Constants.SCRIPTING_TYPE_UTTERANCES) } else if (filename.endsWith('.scriptingmemory.txt')) { result = this.ReadScriptFromBuffer(scriptBuffer, Constants.SCRIPTING_FORMAT_TXT, Constants.SCRIPTING_TYPE_SCRIPTING_MEMORY) } else if (filename.endsWith('.convo.csv')) { result = this.ReadScriptFromBuffer(scriptBuffer, Constants.SCRIPTING_FORMAT_CSV, Constants.SCRIPTING_TYPE_CONVO) } else if (filename.endsWith('.pconvo.csv')) { result = this.ReadScriptFromBuffer(scriptBuffer, Constants.SCRIPTING_FORMAT_CSV, Constants.SCRIPTING_TYPE_PCONVO) } else if (filename.endsWith('.pconvo.csv')) { result = this.ReadScriptFromBuffer(scriptBuffer, Constants.SCRIPTING_FORMAT_CSV, Constants.SCRIPTING_TYPE_PCONVO) } else if (filename.endsWith('.utterance.csv')) { result = this.ReadScriptFromBuffer(scriptBuffer, Constants.SCRIPTING_FORMAT_CSV, Constants.SCRIPTING_TYPE_UTTERANCES) } else if (filename.endsWith('.yaml') || filename.endsWith('.yml')) { result = this.ReadScriptFromBuffer(scriptBuffer, Constants.SCRIPTING_FORMAT_YAML, [Constants.SCRIPTING_TYPE_UTTERANCES, Constants.SCRIPTING_TYPE_PCONVO, Constants.SCRIPTING_TYPE_CONVO, Constants.SCRIPTING_TYPE_SCRIPTING_MEMORY]) } else if (filename.endsWith('.json')) { result = this.ReadScriptFromBuffer(scriptBuffer, Constants.SCRIPTING_FORMAT_JSON, [Constants.SCRIPTING_TYPE_UTTERANCES, Constants.SCRIPTING_TYPE_PCONVO, Constants.SCRIPTING_TYPE_CONVO, Constants.SCRIPTING_TYPE_SCRIPTING_MEMORY]) } else if (filename.endsWith('.markdown') || filename.endsWith('.md')) { result = this.ReadScriptFromBuffer(scriptBuffer, Constants.SCRIPTING_FORMAT_MARKDOWN, [Constants.SCRIPTING_TYPE_UTTERANCES, Constants.SCRIPTING_TYPE_PCONVO, Constants.SCRIPTING_TYPE_CONVO]) } else { debug(`ReadScript - dropped file: ${filename}, filename not supported`) } } catch (err) { debug(`ReadScript - an error occurred at '${filename}' file: ${err}`) throw botiumErrorFromErr(`ReadScript - an error occurred at '${filename}' file: ${err.message}`, err) } // Compilers saved the convos, and we alter here the saved version too if (result.convos && result.convos.length > 0) { result.convos.forEach((fileConvo) => { fileConvo.sourceTag = { convoDir, filename } if (!fileConvo.header.name) { fileConvo.header.name = filename } }) const isSkip = (c) => c.header.name && skipPattern.test(c.header.name.toLowerCase()) result.convos.filter(c => isSkip(c)).forEach(c => debug(`ReadScript - skipping convo '${c.header.name}'`)) result.convos = result.convos.filter(c => !isSkip(c)) } if (result.pconvos && result.pconvos.length > 0) { result.pconvos.forEach((filePartialConvo) => { filePartialConvo.sourceTag = { convoDir, filename } if (!filePartialConvo.header.name) { filePartialConvo.header.name = filename } }) } if (result.scriptingMemories && result.scriptingMemories.length > 0) { result.scriptingMemories.forEach((scriptingMemory) => { scriptingMemory.sourceTag = { filename } }) } if (result.utterances) { result.utterances = this._tagAndCleanupUtterances(result.utterances, convoDir, filename) } return { convos: result.convos || [], utterances: result.utterances || [], pconvos: result.pconvos || [], scriptingMemories: result.scriptingMemories || [] } } _tagAndCleanupUtterances (utteranceFiles, convoDir, filename) { return utteranceFiles.map((fileUtt) => { fileUtt.sourceTag = { convoDir, filename } fileUtt.utterances = fileUtt.utterances.filter(u => u) return fileUtt }) } ExpandScriptingMemoryToConvos () { if (!this.caps[Capabilities.SCRIPTING_ENABLE_MEMORY]) { debug('ExpandScriptingMemoryToConvos - Scripting memory turned off, no convos expanded') return } // validating scripting memory without name const aggregatedNoNames = this.scriptingMemories.filter((entry) => { return !entry.header.name }) if (aggregatedNoNames.length) { throw new BotiumError( 'Scripting Memory Definition(s) without name', { type: 'Scripting Memory', subtype: 'Scripting Memory without name', source: 'ScriptingProvider', cause: { aggregatedNoNames } } ) } // validating scripting memory without variable const aggregatedNoVariables = this.scriptingMemories.filter((entry) => { return !entry.values || !Object.keys(entry.values).length }) if (aggregatedNoVariables.length) { throw new BotiumError( `Scripting Memory Definition(s) ${aggregatedNoVariables.map(e => e.header.name).join(', ')} without variable`, { type: 'Scripting Memory', subtype: 'Scripting Memory without variable', source: 'ScriptingProvider', cause: { aggregatedNoVariables } } ) } // validating scripting memory without variable name const aggregatedNoVariableNames = this.scriptingMemories.filter((entry) => { return !_.isUndefined(entry.values['']) }) if (aggregatedNoVariableNames.length) { throw new BotiumError( `Scripting Memory Definition(s) ${aggregatedNoVariableNames.map(e => e.header.name).join(', ')} without variable name`, { type: 'Scripting Memory', subtype: 'Scripting Memory without variable name', source: 'ScriptingProvider', cause: { aggregatedNoVariableNames } } ) } // validating scripting memory name collision const aggregatedDuplicates = [] for (let i = 0; i < (this.scriptingMemories || []).length; i++) { const scriptingMemory = this.scriptingMemories[i] const duplicate = this.scriptingMemories.filter((entry, j) => { if (j === i || !(entry.values && scriptingMemory.values && entry.header && scriptingMemory.header)) { return false } return (entry.header.name === scriptingMemory.header.name) && (JSON.stringify(Object.keys(entry.values)) === JSON.stringify(Object.keys(scriptingMemory.values))) }) if (duplicate.length) { aggregatedDuplicates.push({ scriptingMemory, duplicate }) } } if (aggregatedDuplicates.length) { throw new BotiumError( `Scripting Memory Definition name(s) "${_.uniq(aggregatedDuplicates.map(d => d.scriptingMemory.header.name)).join(', ')}" are not unique`, { type: 'Scripting Memory', subtype: 'Scripting Memory name collision', source: 'ScriptingProvider', cause: { aggregatedDuplicates } } ) } // validating scripting memory variable name collision const aggregatedIntersections = [] for (let i = 0; i < (this.scriptingMemories || []).length; i++) { const scriptingMemory = this.scriptingMemories[i] const intersection = this.scriptingMemories.filter((entry, j) => { if (j === i || !(entry.values && scriptingMemory.values && entry.header && scriptingMemory.header)) { return false } const k1 = Object.keys(entry.values) const k2 = Object.keys(scriptingMemory.values) const kInt = _.intersection(k1, k2) return kInt.length && (kInt.length !== k1.length || kInt.length !== k2.length) }) if (intersection.length) { aggregatedIntersections.push({ scriptingMemory, intersection }) } } if (aggregatedIntersections.length) { throw new BotiumError( `Scripting Memory Definitions "${aggregatedIntersections.map(i => i.scriptingMemory.header.name).join(', ')}" are invalid because variable name collision"`, { type: 'Scripting Memory', subtype: 'Scripting Memory variable name collision', source: 'ScriptingProvider', cause: { aggregatedIntersections } } ) } const variablesToScriptingMemory = new Map() this.scriptingMemories.forEach((scriptingMemory) => { const key = JSON.stringify(Object.keys(scriptingMemory.values).sort()) if (variablesToScriptingMemory.has(key)) { variablesToScriptingMemory.get(key).push(scriptingMemory) } else { variablesToScriptingMemory.set(key, [scriptingMemory]) } }) let convosExpandedAll = [] const convosOriginalAll = [] this.convos.forEach((convo) => { const convoVariables = convo.GetScriptingMemoryAllVariables(this) debug(`ExpandScriptingMemoryToConvos - Convo "${convo.header.name}" - Variables to replace, all: "${util.inspect(convoVariables)}"`) if (!convoVariables.length) { debug(`ExpandScriptingMemoryToConvos - Convo "${convo.header.name}" - skipped, no variable found to replace`) } let convosToExpand = [convo] let convosExpandedConvo = [] // just for debug output. If we got 6 expanded convo, then this array can be for example [2, 3] const multipliers = [] for (const [key, scriptingMemories] of variablesToScriptingMemory.entries()) { const variableNames = JSON.parse(key) if (_.intersection(variableNames, convoVariables).length) { const convosExpandedVariable = [] multipliers.push(scriptingMemories.length) scriptingMemories.forEach((scriptingMemory) => { // Appending the case name to name for (const convoToExpand of convosToExpand) { const convoExpanded = _.cloneDeep(convoToExpand) convoExpanded.header.name = convoToExpand.header.name + '.' + scriptingMemory.header.name variableNames.forEach((name) => { const value = scriptingMemory.values[name] if (value) { convoExpanded.beginLogicHook.push({ name: 'SET_SCRIPTING_MEMORY', args: [name.substring(1), scriptingMemory.values[name]] }) } else { convoExpanded.beginLogicHook.push({ name: 'CLEAR_SCRIPTING_MEMORY', args: [name.substring(1)] }) } }) convosExpandedVariable.push(convoExpanded) } }) // This is a bit tricky. If the loop is done, then convosExpandedConvo will be used, // otherwise convosToExpand. They could be one variable convosToExpand = convosExpandedVariable convosExpandedConvo = convosExpandedVariable } else { debug(`ExpandScriptingMemoryToConvos - Convo "${convo.header.name}" - Scripting memory ${key} ignored because there is no common variable with convo ${util.inspect(convoVariables)}`) } } debug(`ExpandScriptingMemoryToConvos - Convo "${convo.header.name}" - Expanding convo "${convo.header.name}" Expanded ${convosExpandedConvo.length} convo. (Details: ${convosExpandedConvo.length} = ${multipliers ? multipliers.join('*') : 0})`) if (convosExpandedConvo.length) { convosExpandedAll = convosExpandedAll.concat(convosExpandedConvo) convosOriginalAll.push(convo) } }) if (this.caps[Capabilities.SCRIPTING_MEMORYEXPANSION_KEEP_ORIG] !== true) { debug(`ExpandScriptingMemoryToConvos - Deleting ${convosOriginalAll.length} original convo`) this.convos = this.convos.filter((convo) => convosOriginalAll.indexOf(convo) === -1) } debug(`ExpandScriptingMemoryToConvos - ${convosExpandedAll.length} convo expanded, added to convos (${this.convos.length}). Result ${convosExpandedAll.length + this.convos.length} convo`) this.convos = this.convos.concat(convosExpandedAll) this._sortConvos() } ExpandUtterancesToConvos ({ useNameAsIntent, incomprehensionIntents, incomprehensionUtts, incomprehensionUtt } = {}) { const expandedConvos = [] if (_.isUndefined(useNameAsIntent)) { useNameAsIntent = !!this.caps[Capabilities.SCRIPTING_UTTEXPANSION_USENAMEASINTENT] } if (_.isUndefined(incomprehensionIntents)) { incomprehensionIntents = this.caps[Capabilities.SCRIPTING_UTTEXPANSION_INCOMPREHENSIONINTENTS] } if (_.isUndefined(incomprehensionUtts)) { incomprehensionUtts = this.caps[Capabilities.SCRIPTING_UTTEXPANSION_INCOMPREHENSIONUTTS] } if (_.isUndefined(incomprehensionUtt)) { incomprehensionUtt = this.caps[Capabilities.SCRIPTING_UTTEXPANSION_INCOMPREHENSION] } if (incomprehensionUtt && (!incomprehensionUtts || incomprehensionUtts.length === 0) && !this.utterances[incomprehensionUtt]) { throw new Error(`ExpandUtterancesToConvos - incomprehension utterance '${incomprehensionUtt}' undefined (and no user examples given)`) } if (incomprehensionUtts && incomprehensionUtts.length > 0) { if (!incomprehensionUtt) { incomprehensionUtt = 'UTT_INCOMPREHENSION' } if (this.utterances[incomprehensionUtt]) { this.utterances[incomprehensionUtt].utterances.push(...incomprehensionUtts) } else { this.utterances[incomprehensionUtt] = { name: incomprehensionUtt, utterances: [...incomprehensionUtts] } } } if (useNameAsIntent) { debug('ExpandUtterancesToConvos - Using utterance name as NLU intent') } if (incomprehensionIntents && incomprehensionIntents.length > 0) { debug(`ExpandUtterancesToConvos - Using ${incomprehensionIntents.length} incomprehension NLU intent(s)`) } if (incomprehensionUtt) { debug(`ExpandUtterancesToConvos - Using incomprehension utterance expansion mode: ${incomprehensionUtt}, ${this.utterances[incomprehensionUtt].utterances.length} user example(s)`) } _.keys(this.utterances).filter(u => u !== incomprehensionUtt).forEach(uttName => { const utt = this.utterances[uttName] const responseStep = { sender: 'bot', messageText: '', asserters: [], stepTag: 'Step 2 - check bot response', not: false } if (useNameAsIntent) { responseStep.asserters.push({ name: 'INTENT', args: [utt.name] }) } if (incomprehensionIntents && incomprehensionIntents.length > 0) { incomprehensionIntents.forEach(ii => { responseStep.asserters.push({ name: 'INTENT', args: [ii], not: true }) }) } if (incomprehensionUtt) { responseStep.messageText = incomprehensionUtt responseStep.not = true } expandedConvos.push(new Convo(this._buildScriptContext(), { header: { name: utt.name, description: `Expanded Utterances - ${utt.name}` }, conversation: [ { sender: 'me', logicHooks: [ { name: 'SKIP_BOT_UNCONSUMED' } ], messageText: utt.name, stepTag: 'Step 1 - tell utterance' }, responseStep ], sourceTag: Object.assign({}, utt.sourceTag, { origUttName: utt.name }) })) }) this.convos = this.convos.concat(expandedConvos) this._sortConvos() } ExpandConvos (options = {}) { options = Object.assign({ // use skip and keep, or justHeader justHeader: false, // drop unwanted convos convoFilter: null }, options) const expandedConvos = [] // The globalContext is going to keep the data even if the Object.assign which happening to create the myContext in _expandConvo function const context = { globalContext: { totalConvoCount: 0 } } debug(`ExpandConvos - Using utterances expansion mode: ${this.caps[Capabilities.SCRIPTING_UTTEXPANSION_MODE]}`) this.convos.forEach((convo) => { convo.expandPartialConvos() for (const expanded of this._expandConvo(convo, options, context)) { expanded.header.assertionCount = this.GetAssertionCount(expanded) if (options.justHeader) { const ConvoWithOnlyHeader = { header: { name: expanded.header.name, assertionCount: expanded.header.assertionCount } } expandedConvos.push(ConvoWithOnlyHeader) } else { expandedConvos.push(expanded) } } }) this.convos = expandedConvos this.totalConvoCount = context.globalContext.totalConvoCount if (!options.justHeader) { this._sortConvos() } else { this._updateConvos() } } ExpandConvosIterable (options = {}) { options = Object.assign({ // drop unwanted convos convoFilter: null }, options) // The globalContext is going to keep the data even if the Object.assign which happening to create the myContext in _expandConvo function const context = { globalContext: { totalConvoCount: 0 } } debug(`ExpandConvos - Using utterances expansion mode: ${this.caps[Capabilities.SCRIPTING_UTTEXPANSION_MODE]}`) // creating a nested generator, calling the other. // We hope this.convos does not changes while this iterator is used const _convosIterable = function * (options) { for (const convo of this.convos) { convo.expandPartialConvos() yield * this._expandConvo(convo, options, context) } }.bind(this) this.convosIterable = _convosIterable(options) this.totalConvoCount = context.globalContext.totalConvoCount } /** * This is a generator function with yield * @param currentConvo * @param convoStepIndex * @param convoStepsStack list of ConvoSteps * @private */ * _expandConvo (currentConvo, options, context, convoStepIndex = 0, convoStepsStack = []) { const utterancePostfix = (lineTag, uttOrUserInput) => { const naming = this.caps[Capabilities.SCRIPTING_UTTEXPANSION_NAMING_MODE] || Defaults.capabilities[Capabilities.SCRIPTING_UTTEXPANSION_NAMING_MODE] if (naming === 'justLineTag') { return `L${lineTag}` } const utteranceMax = this.caps[Capabilities.SCRIPTING_UTTEXPANSION_NAMING_UTTERANCE_MAX] || 0 let postfix if (utteranceMax > 3 && uttOrUserInput.length > utteranceMax) { postfix = uttOrUserInput.substring(0, utteranceMax - 3) + '...' } else { postfix = uttOrUserInput } return `L${lineTag}-${postfix}` } if (convoStepIndex < currentConvo.conversation.length) { const currentStep = currentConvo.conversation[convoStepIndex] if (currentStep.sender === 'bot' || currentStep.sender === 'begin' || currentStep.sender === 'end') { const currentStepsStack = convoStepsStack.slice() currentStepsStack.push(_.cloneDeep(currentStep)) yield * this._expandConvo(currentConvo, options, context, convoStepIndex + 1, currentStepsStack) } else if (currentStep.sender === 'me') { let useUnexpanded = true if (currentStep.messageText) { let uttName = null let uttArgs = null if (this.utterances[currentStep.messageText]) { uttName = currentStep.messageText } else { const parts = currentStep.messageText.split(' ') if (this.utterances[parts[0]]) { uttName = parts[0] uttArgs = parts.slice(1) } } if (this.utterances[uttName]) { const allutterances = this.utterances[uttName].utterances const processSampleUtterances = function * (sampleutterances, myContext) { for (let index = 0; index < sampleutterances.length; index++) { yield * processSampleUtterance(sampleutterances[index], sampleutterances.length, index, Object.assign({ indexExpansionModeIndex: index }, myContext || context)) } } const processSampleUtterance = function * (sampleutterance, length, index, myContext) { const currentStepsStack = convoStepsStack.slice() if (uttArgs) { sampleutterance = util.format(sampleutterance, ...uttArgs) } currentStepsStack.push(Object.assign(_.cloneDeep(currentStep), { messageText: sampleutterance })) const currentConvoLabeled = _.cloneDeep(currentConvo) if (length > 1) { const lineTag = `${index + 1}`.padStart(`${length}`.length, '0') Object.assign(currentConvoLabeled.header, { name: `${currentConvo.header.name}/${uttName}-${utterancePostfix(lineTag, sampleutterance)}` }) } if (!currentConvoLabeled.sourceTag) currentConvoLabeled.sourceTag = {} if (!currentConvoLabeled.sourceTag.origConvoName) currentConvoLabeled.sourceTag.origConvoName = currentConvo.header.name yield * this._expandConvo(currentConvoLabeled, options, myContext || context, convoStepIndex + 1, currentStepsStack) }.bind(this) if (allutterances.length === 1) { yield * processSampleUtterances([allutterances[0]], context) } else if (this.caps[Capabilities.SCRIPTING_UTTEXPANSION_MODE] === 'index') { if (_.isNil(context.indexExpansionModeWidth)) { // executed for the first found utterance yield * processSampleUtterances(allutterances, Object.assign({}, context, { indexExpansionModeWidth: allutterances.length })) } else { if (_.isNil(context.indexExpansionModeIndex)) { throw new Error('indexExpansionModeIndex must be set!') } // executing the current 'thread', if current utterance has no example to current index, fallback to the last one const localIndex = Math.min(context.indexExpansionModeIndex, allutterances.length - 1) if (localIndex < context.indexExpansionModeIndex && context.indexExpansionModeIndex === context.indexExpansionModeWidth - 1) { debug(`While expanding convos by index found in utterance "${uttName}" less examples (${allutterances.length}) as expected (${context.indexExpansionModeWidth})`) } const myContext = Object.assign({}, context, { indexExpansionModeWidth: Math.max(allutterances.length, context.indexExpansionModeWidth) }) yield * processSampleUtterance(allutterances[localIndex], allutterances.length, localIndex, myContext) if (allutterances.length > context.indexExpansionModeWidth && context.indexExpansionModeIndex + 1 === context.indexExpansionModeWidth) { debug(`While expanding convos by index found in utterance "${uttName}" more examples (${allutterances.length}) as expected (${context.indexExpansionModeWidth})`) for (let i = context.indexExpansionModeWidth; i < allutterances.length; i++) { // if we found a utterance with more examples as any utterances before, we have to start new 'thread' const myContext = Object.assign({}, context, { indexExpansionModeWidth: allutterances.length, indexExpansionModeIndex: i }) yield * processSampleUtterance(allutterances[i], allutterances.length, i, myContext) } } } } else { if (this.caps[Capabilities.SCRIPTING_UTTEXPANSION_MODE] === 'first') { yield * processSampleUtterances([allutterances[0]], context) } el