Azureを使ってSalesforceでAIを活用する ~後編~

この記事は「Azureを使ってSalesforceでAIを活用する ~前編~」の続きとなる後編です。
Salesforceで管理している見積情報をAzure Machine Learning(以下AzureML)の機械学習を使って学ばせて、見積もりをする時に工数を予測しようというものです。
前編ではAzureML側で学習済モデルを作り、WebAPIとして公開するところまで作りました。
後編となる今回はSalesforceからAzureMLのWebAPIを呼び出し、見積の情報を渡して学習結果から返される予測された工数を表示してみましょう。

概要図

流れとしては以下のようになります。

見積オブジェクトの標準レイアウトにVisualforceを埋め込んで、そこからボタンで処理を起動します。
ボタンが押されるとApexのControllerで画面に表示している見積レコードからJSONを作り、AzureMLのWebAPIへ渡します。
前編で作成した学習済みのモデルが渡ってきた見積情報から実績工数を算出してJSONで返します。
それを受け取ったら画面上に「実績工数はこのくらいかかる見込みですよ」と表示させることにします。

リモートサイトの設定


まずSalesforceから外部サイト(今回はAzure)を呼び出すためには、あらかじめリモートサイトに登録しておく必要があります。
設定メニューからリモートサイトを新規作成します。

URLはAzureML側で作成したAPIのドキュメントページに書いてあるリクエストのエンドポイントを参照してください。
私の場合はWorkSpaceを東南アジアに作ったので、URLは「https://asiasoutheast.services.azureml.net」としています。

Apexを作る


続いて今回のメインとなるApexを作っていきましょう。
まずは全体のコードを提示して、後でポイントとなる部分をいくつか解説していきます。

public with sharing class AzureEstimateCtrl {
private final String requestUrl = '';
private Estimate__c record;
public String screenMsg {get;set;}
public AzureEstimateCtrl(ApexPages.StandardController stdCtrl) {
this.record = (Estimate__c)stdCtrl.getRecord();
}
public PageReference forcastWorkLoad() {
Estimate__c record = [SELECT IsNewCustomer__c,
ContractForm__c,
ObjectNum__c,
DevelopScreenNum__c,
ApexTriggerNum__c,
ConnectionScriptNum__c,
MemberNum__c,
TotalPeriod__c
FROM Estimate__c 
WHERE Id =: record.Id];
Map<String, Object> fieldsToValue = record.getPopulatedFieldsAsMap();
List<String> columnNames  = new List<String>();
List<String> values = new List<String>();
for(String fieldName : fieldsToValue.keySet()){
if(fieldName == 'Id') {
// requestのIFに入れてないので除外
continue;
}
columnNames.add( getFieldLabel(fieldName) );
values.add( String.valueOf( fieldsToValue.get(fieldName) ) );
}
String requestBody = createRequestJson(columnNames, values);
HttpRequest req = new HttpRequest();
// AzureML側のAPIドキュメントに記載された情報を入れる. xxxxxxx部分は書き換えること
req.setEndpoint('https://asiasoutheast.services.azureml.net/workspaces/xxxxxxx/services/xxxxxxx/execute?api-version=2.0&details=true');
req.setMethod('POST');
req.setHeader('Authorization', 'Bearer ' + 'xxxxxxx');
req.setHeader('Content-Length', String.valueOf(requestBody.length()));
req.setHeader('Content-Type', 'application/json');
req.setBody(requestBody);
Http h = new Http();
HttpResponse res = h.send(req);
System.debug(res.getBody());
if(res.getStatusCode() == 200){
Double score = getScore(res.getBody());
screenMsg = 'この見積の予想総工数は「' + String.valueOf(score) + '(人月)」です。';
} else {
screenMsg = 'エラー:' + res.getBody();
}
return null;
}
private String createRequestJson(List<String> columnName, List<String> oneRecVals){
List<List<String>> values = new List<List<String>>();
values.add(oneRecVals);
JSONGenerator gen = JSON.createGenerator(false);
gen.writeStartObject();
gen.writeFieldName('Inputs');
gen.writeStartObject();
gen.writeFieldName('input1');
gen.writeStartObject();
gen.writeObjectField('ColumnNames', columnName);
gen.writeObjectField('Values', values);
gen.writeEndObject();
gen.writeEndObject();
gen.writeFieldName('GlobalParameters');
gen.writeStartObject();
// GlobalParam無し
gen.writeEndObject();
gen.writeEndObject();
String pretty = gen.getAsString();
System.debug(pretty);
return pretty;
}
private Double getScore(String responseBody){
ResponceValues resObj;
JSONParser parser = JSON.createParser(responseBody);
while(parser.nextToken() != null) {
if ((parser.getCurrentToken() == JSONToken.FIELD_NAME) && (parser.getText() == 'value')) {
parser.nextToken();
resObj = (ResponceValues)parser.readValueAs(ResponceValues.class);
parser.skipChildren();
}
}
Integer scoreColumnNum = getTargetColumnNum(resObj.columnNames, 'Scored Labels');
List<String> values = resObj.values.get(0); // 今回は1件のみ
return Double.valueof(values.get(scoreColumnNum));
}
private Integer getTargetColumnNum(List<String> ls, String columnName) {
for(Integer i = 0; i < ls.size(); i++) {
if(ls[i] == columnName) {
return i;
}
}
return -1;
}
private String getFieldLabel(String fieldApiName) {
Schema.SObjectField sobjFld = fieldMetaMap.get(fieldApiName);
Schema.DescribeFieldResult describeFld = sobjFld.getDescribe();
return describeFld.getLabel();
}
private Map<String, Schema.SObjectField> fieldMetaMap{
get {
if(fieldMetaMap == null){
Schema.DescribeSObjectResult dsr = Estimate__c.sObjectType.getDescribe();
fieldMetaMap = dsr.fields.getMap(); 
}
return fieldMetaMap;
}
set;
}
private class ResponceValues {
public List<String> columnNames {get; set;}
public List<String> columnTypes {get; set;}
public List<List<String>> values {get; set;}
}
}

基本的にやることは見積レコードからAPIの仕様に沿ったJSONを作り、その後APIを呼び出す。
結果のJSONを受け取り画面に表示する。これだけです。

どういったJOSNを作ればいいかというのはAzureML側で作ったWebAPIのドキュメントにサンプルが自動生成されています。
今回はこんな感じです。

{
"Inputs": {
"input1": {
"ColumnNames": [
"新規顧客",
"契約形態",
"オブジェクト数",
"開発画面数",
"Apexトリガ数",
"連携スクリプト数",
"参画メンバー数",
"総期間(月)"
],
"Values": [
[
"0",
"value",
"0",
"0",
"0",
"0",
"0",
"0"
],
[
"0",
"value",
"0",
"0",
"0",
"0",
"0",
"0"
]
]
}
},
"GlobalParameters": {}
}

"ColumnNames"に項目名、"Values"に配列で値を入れたJSONを作るようです。

ということでSOQLで必要な情報を取得しJSONを作ります。

		Estimate__c record = [SELECT IsNewCustomer__c,
ContractForm__c,
ObjectNum__c,
DevelopScreenNum__c,
ApexTriggerNum__c,
ConnectionScriptNum__c,
MemberNum__c,
TotalPeriod__c
FROM Estimate__c 
WHERE Id =: record.Id];
Map<String, Object> fieldsToValue = record.getPopulatedFieldsAsMap();
List<String> columnNames  = new List<String>();
List<String> values = new List<String>();
for(String fieldName : fieldsToValue.keySet()){
if(fieldName == 'Id') {
// requestのIFに入れてないので除外
continue;
}
columnNames.add( getFieldLabel(fieldName) );
values.add( String.valueOf( fieldsToValue.get(fieldName) ) );
}
String requestBody = createRequestJson(columnNames, values);

getFieldLabelでは項目のAPI名からラベル名を取得しています。
最初からAzureML側のインターフェースをAPI参照名で作っておけばこの辺は省略できますが、今回はAzureML側での分かりやすさを優先しました。

createRequestJsonでAPIの仕様に沿ったJSONを作っています。

次にリクエストを作成して投げています。

		HttpRequest req = new HttpRequest();
// AzureML側のAPIドキュメントに記載された情報を入れる. xxxxxxx部分は書き換えること
req.setEndpoint('https://asiasoutheast.services.azureml.net/workspaces/xxxxxxx/services/xxxxxxx/execute?api-version=2.0&details=true');
req.setMethod('POST');
req.setHeader('Authorization', 'Bearer ' + 'xxxxxxx');
req.setHeader('Content-Length', String.valueOf(requestBody.length()));
req.setHeader('Content-Type', 'application/json');
req.setBody(requestBody);
Http h = new Http();
HttpResponse res = h.send(req);

リクエストのエンドポイントはAPIドキュメントに記載されています。
またリクエストヘッダーの"Authorization"にAPI KEYをセットします。こちらもAzureML側のWebAPIのページに記載されています。
ヘッダーをセットし、ボディにリクエストのJSONをセットしたらコールします。

後は戻ってきた結果を画面に表示するだけです。

		if(res.getStatusCode() == 200){
Double score = getScore(res.getBody());
screenMsg = 'この見積の予想総工数は「' + String.valueOf(score) + '(人月)」です。';
} else {
screenMsg = 'エラー:' + res.getBody();
}

getScoreでは戻ってきたJSONから工数予測値を抜き出しています。
JSONの戻りの形式もAPIドキュメントにサンプルが載っているので確認すると良いでしょう。

これでApexは完成です。

Visualforceを作る


Visualforceは簡単です。

<apex:page standardController="Estimate__c" extensions="AzureEstimateCtrl">
<apex:form >
<script type="text/javascript">
function loading(){
document.getElementById("{!$Component.loading}").style="display:inline";
}
</script>
<apex:commandButton onclick="loading()" action="{!forcastWorkLoad}" value="予測実行" />
<apex:image id="loading" value="/img/loading.gif" style="display:none"/>
</apex:form>
<p>{!screenMsg}</p>
</apex:page>

標準ページレイアウトに埋め込むのでstandardControllerで見積オブジェクトを指定し、拡張クラスとして作成したApexクラスを指定します。
後はボタンを実装してactionにApexクラスで作ったリクエストコールの処理を呼び出すだけです。

その他設定


最後はVisualforceを見積オブジェクトのページレイアウトに配置します。


動作確認


完成したので画面から実行してみましょう。

実行結果

サンプルで入力した見積は5人参画で7ヶ月の見積でしたが、予測工数が47.5人月でした。
このままだと炎上プロジェクト待ったなしになので、メンバーや期間を再考した方がいいかもしれません。(あくまでシナリオ上のお話ですが)

最後に


前編後編に渡ってAzureとSalesforceで機械学習を使った連携をご紹介しました。
SalesforceではIQだけではなく今後もAIに関連するサービスが出てくる見込みですが、おそらく汎用的なAIサービスはプラットフォームとして出してこないと思われます。
個人的な憶測ですが、あくまでSalesCloud、ServiceCloud、MarketingCloudなどのサービスの補助的な位置づけとして、AIの機能を提供されるのではないかと思います。
そうした時に独自のアプリケーションに機械学習を取り入れたい場合、AzureやAmazonなどのクラウドサービスを組み合わせてみるというのは一つの選択肢になるでしょう。

人工知能を取り入れた製品が次々と登場してきていますが、自動運転、自動翻訳、介護や教育など、活用のエリアはまだまだ沢山あります。
今後さらに機械学習の知識が必要となる場面も増えてくると思われるので、ぜひこの機会に触ってみてください。