node-red-node-watson
Version:
A collection of Node-RED nodes for IBM Watson services
482 lines (426 loc) • 15.6 kB
JavaScript
/**
* Copyright 2013,2022 IBM Corp.
*
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
**/
module.exports = function (RED) {
// Require the Cloud Foundry Module to pull credentials from bound service
// If they are found then they are stored in sApikey, as the
// service credentials. This separation is to allow
// the end user to modify the node credentials when the service is not bound.
// Otherwise, once set apikey would never get reset, resulting in a frustrated
// user who, when he errenously enters bad credentials, can't figure out why
// the edited ones are not being taken.
const SERVICE_IDENTIFIER = 'language-translator',
LanguageTranslatorV3 = require('ibm-watson/language-translator/v3'),
{ IamAuthenticator } = require('ibm-watson/auth');
var pkg = require('../../package.json'),
//cfenv = require('cfenv'),
payloadutils = require('../../utilities/payload-utils'),
serviceutils = require('../../utilities/service-utils'),
translatorutils = require('./translator-utils'),
responseutils = require('../../utilities/response-utils'),
fs = require('fs'),
temp = require('temp'),
apikey = null,
sApikey = null,
//service = cfenv.getAppEnv().getServiceCreds(/language translator/i),
service = serviceutils.getServiceCreds(SERVICE_IDENTIFIER),
endpoint = '',
sEndpoint = 'https://gateway.watsonplatform.net/language-translator/api';
//endpointUrl = 'https://gateway.watsonplatform.net/language-translator/api';
temp.track();
if (service) {
sApikey = service.apikey ? service.apikey : '';
sEndpoint = service.url;
}
// These are APIs that the node has created to allow it to dynamically fetch IBM Cloud
// credentials, and also translation models. This allows the node to keep up to
// date with new tranlations, without the need for a code update of this node.
// Node RED Admin - fetch and set vcap services
RED.httpAdmin.get('/watson-translator/vcap', function (req, res) {
res.json(service ? {bound_service: true} : null);
});
// API used by widget to fetch available models
RED.httpAdmin.get('/watson-translator/models', function (req, res) {
//endpoint = sEndpoint ? sEndpoint : req.query.e;
endpoint = req.query.e ? req.query.e : sEndpoint;
var lt = null,
authSettings = {},
serviceSettings = {
version: '2018-05-01',
url: endpoint,
headers: {
'User-Agent': pkg.name + '-' + pkg.version
}
};
if (sApikey || req.query.key) {
authSettings.apikey = sApikey ? sApikey : req.query.key;
}
serviceSettings.authenticator = new IamAuthenticator(authSettings);
lt = new LanguageTranslatorV3(serviceSettings);
lt.listModels({})
.then((response) => {
let models = [];
if (response && response.result && response.result.models) {
models = response.result;
}
res.json(models);
})
.catch((err) => {
res.json(err);
});
});
// This is the Language Translation Node.
// The node supports four modes
//
// 1. translate, for which it will specify a domain, obtained from the available models
// along with source and target languages. The node will have only displayed
// available translations for the model / domain
// 2. train, for which a glossary file is required.
// 3. status, to determine whethere a trained corpus is available
// 4. delete, to remove a trained corpus extension.
function SMTNode (config) {
RED.nodes.createNode(this, config);
var node = this;
function payloadCheck(msg) {
if (!msg.payload) {
return Promise.reject('Missing property: msg.payload');
}
return Promise.resolve();
}
function checkForGlobalOverides(msg) {
// If the selection is to use global overrides then
// look for them
if (config.lgparams2 === false) {
var globalContext = this.context().global,
tmpmodel_id = globalContext.get('g_model_id');
if (tmpmodel_id && tmpmodel_id.length > 1) {
var result = tmpmodel_id.split('-');
//msg.model_id = tmpmodel_id;
msg.domain = result[2];
msg.srclang = result[0];
msg.destlang = result[1];
}
}
return Promise.resolve();
}
// If a translation is requested, then the model id will have been
// built by the calling function based on source, target and domain.
function doTranslate(language_translator, msg, model_id) {
let p = new Promise(function resolver(resolve, reject) {
// Please be careful when reading the below. The first parameter is
// a structure, and the tabbing enforced by codeacy imho obfuscates
// the code, rather than making it clearer. I would have liked an
// extra couple of spaces.
language_translator.translate({
text: msg.payload,
modelId: model_id
})
.then((response) => {
responseutils.parseResponseFor(msg, response, 'translations');
msg.translation = msg.translations;
msg.payload = msg.translations[0].translation;
resolve();
})
.catch((err) => {
reject(err);
});
});
return p;
}
function determineModelId(msg) {
let domain = msg.domain || config.domain,
srclang = msg.srclang || config.srclang,
destlang = msg.destlang || config.destlang,
model_id = '';
if (!domain) {
return Promise.reject('Missing translation domain, message not translated');
}
if (!srclang) {
return Promise.reject('Missing source language, message not translated');
}
if (!destlang) {
return Promise.reject('Missing target language, message not translated');
}
model_id = srclang + '-' + destlang;
if (domain !== 'news' && domain !== 'general') {
model_id += ('-' + domain);
}
return Promise.resolve(model_id);
}
function determineCustomModelId(msg) {
var custom = msg.custom || config.custom;
if (!custom) {
return Promise.reject('Missing customised model, message not translated');
}
return Promise.resolve(custom);
}
function performTrainPreChecks(msg) {
var basemodel = msg.basemodel || config.basemodel,
filetype = msg.filetype || config.filetype;
if (!basemodel) {
return Promise.reject('Base Model needs must be set for train mode');
}
if (!filetype) {
return Promise.reject('Filetype needs must be set for train mode');
}
return Promise.resolve({ basemodel:basemodel, filetype:filetype});
}
function loadFile() {
var p = new Promise(function resolver(resolve, reject){
temp.open({
suffix: '.xml'
}, function(err, info) {
if (err) {
reject(err);
} else {
resolve(info);
}
});
});
return p;
}
function syncFile(msg, info) {
var p = new Promise(function resolver(resolve, reject){
// Syncing up the asynchronous nature of the stream
// so that the full file can be sent to the API.
fs.writeFile(info.path, msg.payload, function(err) {
if (err) {
reject(err);
} else {
resolve();
}
});
});
return p;
}
function setTrainParams(msg, info, td) {
var params = {};
// only letters and numbers allowed in the submitted file name
// Default the name to a string representing now
params.name = (new Date()).toString().replace(/[^0-9a-z]/gi, '');
if (msg.filename) {
params.name = msg.filename.replace(/[^0-9a-z]/gi, '');
}
if (params.name.length > 32) {
params.name = params.name.slice(0, 32);
}
params.baseModelId = td.basemodel;
switch (td.filetype) {
case 'forcedglossary':
params.forcedGlossary = fs.createReadStream(info.path);
break;
case 'parallelcorpus':
params.parallelCorpus = fs.createReadStream(info.path);
break;
}
return Promise.resolve(params);
}
function doTrain(language_translator, msg, params) {
var p = new Promise(function resolver(resolve, reject){
language_translator.createModel(params)
.then((response) => {
responseutils.parseResponseFor(msg, response, 'result');
if (msg.result && msg.result.name && msg.result.model_id) {
msg.payload = 'Model ' + msg.result.name + ' successfully sent for training with id: ' + msg.result.model_id;
msg.trained_model_id = msg.result.model_id;
} else {
msg.payload = result;
}
resolve();
})
.catch((err) => {
reject(err);
});
});
return p;
}
function executeDelete(language_translator, msg) {
let p = new Promise(function resolver(resolve, reject) {
let trainid = msg.trainid || config.trainid;
language_translator.deleteModel({modelId: trainid})
.then((response) => {
msg.payload = 'Model ' + trainid + ' has been deleted';
resolve();
})
.catch((err) => {
reject(err);
});
});
return p;
}
// Fetch the status of the trained model. It can only be used if the model
// is available. This will also return any training errors.
// The full error reason is returned in msg.translation
function executeGetStatus(language_translator, msg) {
let p = new Promise(function resolver(resolve, reject) {
let trainid = msg.trainid || config.trainid;
language_translator.getModel({modelId: trainid})
.then((response) => {
responseutils.parseResponseFor(msg, response, 'result');
msg.payload = msg.result.status;
msg.translation = msg.result;
resolve();
})
.catch((err) => {
reject(err);
});
});
return p;
}
function executeListModels(language_translator, msg, onlydefault) {
let p = new Promise(function resolver(resolve, reject) {
language_translator.listModels({_default: onlydefault})
.then((response) => {
responseutils.parseResponseFor(msg, response, 'models');
msg.payload = msg.models;
resolve();
})
.catch((err) => {
reject(err);
});
});
return p;
}
function executeTrain(language_translator, msg) {
let trainingData = {}, p = null, info = null;
p = performTrainPreChecks(msg)
.then(function(td){
trainingData = td;
return loadFile();
})
.then(function(i){
info = i;
return syncFile(msg, info);
})
.then(function(){
return setTrainParams(msg, info, trainingData);
})
.then(function(params){
return doTrain(language_translator, msg, params);
});
return p;
}
function executeCustomTranslate(language_translator, msg) {
let p = determineCustomModelId(msg)
.then(function(model_id){
return doTranslate(language_translator, msg, model_id);
});
return p;
}
function executeTranslate(language_translator, msg) {
let p = determineModelId(msg)
.then(function(model_id){
return doTranslate(language_translator, msg, model_id);
});
return p;
}
function executeAction(msg, action) {
let p = null,
language_translator = null,
authSettings = {},
serviceSettings = {
version: '2018-05-01',
headers: {
'User-Agent': pkg.name + '-' + pkg.version
}
};
if (apikey) {
authSettings.apikey = apikey;
}
serviceSettings.authenticator = new IamAuthenticator(authSettings);
if (endpoint) {
serviceSettings.url = endpoint;
}
language_translator = new LanguageTranslatorV3(serviceSettings);
// We have credentials, and know the mode. Further required fields checks
// are specific to the requested action.
// The required fields are checked, before the relevant function is invoked.
switch (action) {
case 'translate':
p = executeTranslate(language_translator, msg);
break;
case 'custom':
p = executeCustomTranslate(language_translator, msg);
break;
case 'train':
p = executeTrain(language_translator, msg);
break;
case 'getstatus':
p = executeGetStatus(language_translator, msg);
break;
case 'listdefault':
p = executeListModels(language_translator, msg, true);
break;
case 'listcustom':
p = executeListModels(language_translator, msg, false);
break;
case 'delete':
p = executeDelete(language_translator, msg);
break;
default:
p = Promise.reject('Unexpected Mode');
break;
}
//p = Promise.reject('Still Implementing');
return p;
}
// The node has received an input as part of a flow, need to determine
// what the request is for, and based on that if the required fields
// have been provided.
this.on('input', function(msg, send, done) {
var action = msg.action || config.action;
// The dynamic nature of this node has caused problems with the password field. it is
// hidden but not a credential. If it is treated as a credential, it gets lost when there
// is a request to refresh the model list.
// Credentials are needed for each of the modes.
apikey = sApikey || this.credentials.apikey || config.apikey;
endpoint = sEndpoint;
if (config['service-endpoint']) {
endpoint = config['service-endpoint'];
}
node.status({});
translatorutils.credentialCheck(apikey)
.then(function(){
return payloadCheck(msg);
})
.then(function(){
return translatorutils.checkForAction(action);
})
.then(function(){
return checkForGlobalOverides(msg);
})
.then(function(){
node.status({fill:'blue', shape:'dot', text:'executing'});
return executeAction(msg, action);
})
.then(function(){
temp.cleanup();
node.status({});
send(msg);
done();
})
.catch(function(err){
temp.cleanup();
let errMsg = payloadutils.reportError(node, msg, err);
done(errMsg);
});
});
}
RED.nodes.registerType('watson-translator', SMTNode, {
credentials: {
apikey: {type:'password'}
}
});
};