Cobgot!アドベントカレンダーの8日目となります。今日は、ボットアドベントカレンダーからお邪魔しています。両方に同一の記事を予約投稿できなかったため、苦肉の策として前半はボットに、後半はCogbot!に投稿しています。
①概要編
本日(12/8)のボットアドベントカレンダーに投稿した、こちらの記事となります。
https://qiita.com/makopo/items/7f3a8dc6bd931069b9a1
作成しようとしているものの概要とアーキテクチャが書かれていますので、一旦こちらをお読みになってから戻ってきてください。
「画像の感想を述べる」実装
ユーザから「これ見て」的な発言を受け取ったら、「URLは?」と聞いて、URLをヒアリングします。そのURLを使ってAzureのComputer Vision APIでネタになりそうな画像の特徴を拾ってきて、それをTranslator Text APIで和訳し、応答文に含めてユーザに返します。
Dialogflowインテント: Query
画像判定をしてほしい旨のユーザの入力を受け付けたら呼び出されるインテントです。判定に使う画像のURLを貰って、webhook(Azure Functions)に渡すところまでを担当します。
-
User says
これ見て
-
Action
- PARAMETER NAME:
url
- ENTITY:
@sys.any
- VALUE:
$url
- PROMPTS:
画像のURLは?
,URLちょーだい
- PARAMETER NAME:
-
Fulfillment
-
Use webhook
にチェックを入れる
-
webhook (Queryを受ける部分)
Dialogflowからは、webhookとして登録されたプログラムに、このような感じの本文でリクエストを送信してきます。Azure Functionsで、このような形のリクエストを適切に処理できるように実装します。
Visual Studioで、Azure Functions
を新規作成します。新しいAzure関数...
で、Azure Functionsファイル(.cs)を作成します。Http trigger
を選択します。Access rights
はFunction
のままで結構です。Run
メソッドの中を書き換え、intentがQueryの場合は、画像解析APIと翻訳APIを順に呼び出して、結果を返すようにします。
// リクエスト本文
dynamic data = await req.Content.ReadAsAsync<JObject>();
string intent = (string)data["result"]["metadata"]["intentName"];
// インテントがQueryか?
if (intent == "Query")
{
// URLを取得する
string url = (string)data["result"]["resolvedQuery"];
// URLが無い場合は、エラーとする
if (string.IsNullOrEmpty(url))
{
throw new System.ArgumentException("url empty");
}
// 画像解析
OKDataModel resData = await APICaller.MakeAnalysisRequestAsync(url, log);
// 翻訳
OKDataModel translatedData = await APICaller.MakeTranslateRequestAsync(resData, log);
// 返却データを生成して返す
return req.CreateResponse(HttpStatusCode.OK,
new ResponseModel("LOCAL_WEBHOOK_RECEIVED", translatedData),
"application/json");
}
本来であれば
parameters
のurl
からURLを取得すべきですが、以下の例のようにスラッシュが空白に変換された状態で入ってしまうので、止むを得ずresult
>resolvedQuery
から取得するようにしています。@sys.anyに設定した値が変更されてしまう事例は、他にも報告されているようです。"parameters": {
"url": "https cdn pixabay com photo 2016/05/14/12/58 butterfly-1391809 960 720 jpg"
},
画像解析
上で呼び出しているAPICaller.MakeAnalysisRequestAsync
メソッドの中身です。Computer Vision APIのAnalyze Imageを呼び出します。このような感じの本文でレスポンスが返ってきますので、会話のネタになりそうで、ハズレが比較的少ない、以下を抽出します。
- カテゴリ1 (
A_B
のA
の部分) - カテゴリ2 (
A_B
のB
の部分) - キャプション
- 前景色で一番目立つ色
- 背景色で一番目立つ色
internal static async Task<OKDataModel> MakeAnalysisRequestAsync(string url, TraceWriter log)
{
// サブスクリプションキーとエンドポイントURLは、環境変数から取得
string KEY = System.Environment.GetEnvironmentVariable("CV_KEY");
string ENDPOINT = System.Environment.GetEnvironmentVariable("CV_ENDPOINT");
// 環境変数が設定されていない場合はエラー
if (string.IsNullOrEmpty(KEY) || string.IsNullOrEmpty(ENDPOINT))
{
throw new System.ArgumentException("CV_KEY and/or CV_ENDPOINT is empty");
}
HttpClient client = new HttpClient();
// リクエストヘッダ
client.DefaultRequestHeaders.Add("Ocp-Apim-Subscription-Key", KEY);
// リクエストURL
string uri = $"{ENDPOINT}/analyze?visualFeatures=Categories,Description,Color&language=en";
// リクエスト本文
string json = JsonConvert.SerializeObject(new ComputerVisionInputModel() { Url = url });
// Computer Vision APIを実行し、レスポンス本文を取得
HttpResponseMessage response = await client.PostAsync(
uri, new StringContent(json, Encoding.UTF8, "application/json"));
JObject rss = await response.Content.ReadAsAsync<JObject>();
// 正常に復帰していなければエラー
if (!response.IsSuccessStatusCode)
{
throw new System.ApplicationException(
$"ComputingVisionAPI failed. code={rss["code"]}, message={rss["message"]}");
}
// 最もscoreが高いカテゴリを取得
string category1 = "";
string category2 = "";
var categories = rss["categories"];
if (categories != null)
{
JObject top = (JObject)categories.OrderByDescending
(obj => obj["score"]).FirstOrDefault();
// 「カテゴリ1_カテゴリ2」になっているのを分解
string[] work = ((string)top["name"]).Split('_');
category1 = work[0];
category2 = work[1];
}
// Functionのdata部に設定するオブジェクトを作成して返す
return new OKDataModel()
{
Category1 = category1,
Category2 = category2,
Caption = (string)rss["description"]["captions"][0]["text"],
DominantColorForeground = (string)rss["color"]["dominantColorForeground"],
DominantColorBackground = (string)rss["color"]["dominantColorBackground"]
};
}
画像によっては、Computer Vision APIから返されるJSONにcategoriesセクションが無いこともありますので、気をつけてください(蝶々の例)。
(環境変数の利用)
各種APIのサブスクリプションキーやリージョン名が含まれるエンドポイントURLは、ソースに直接書くと、なんだか危険な気がしますので、Azure Functionsの環境変数に定義します。
ローカルでのデバッグ時はプロパティ
->デバッグ
->環境変数
で定義します。
Azureで動作させる際は、Function Appの名前
->アプリケーション設定
->アプリケーション設定
で定義します。
翻訳
2017年12月時点でComputer Vision APIは日本語に対応していないため、結果を日本語に翻訳する必要があります。Translator Text APIを使います。
5個のネタをそれぞれAPIに投げるのは勿体無い気がしますので、絶対にComputer Vision APIから返ってこなさそうな文字、例えば|
を区切り文字として結合して、翻訳をお願いしてみます。
internal static async Task<OKDataModel> MakeTranslateRequestAsync(OKDataModel data, TraceWriter log)
{
// サブスクリプションキーは、環境変数から取得
string KEY = System.Environment.GetEnvironmentVariable("TRANS_KEY");
// 環境変数が設定されていない場合はエラー
if (string.IsNullOrEmpty(KEY))
{
throw new System.ArgumentException("TRANS_KEY is empty");
}
// 翻訳文字列の生成
string srcString = string.Join("|", new string[] {
data.Caption, data.Category1, data.Category2,
data.DominantColorBackground, data.DominantColorForeground});
HttpClient client = new HttpClient();
// リクエストヘッダ
client.DefaultRequestHeaders.Add("Ocp-Apim-Subscription-Key", KEY);
// リクエストURL
string uri = $"https://api.microsofttranslator.com/V2/Http.svc/Translate?text={srcString}&to=ja";
// Translator Text APIを実行し、レスポンス本文を取得
HttpResponseMessage response = await client.GetAsync(uri);
string contentString = await response.Content.ReadAsStringAsync();
// 正常に復帰していなければエラー
if (!response.IsSuccessStatusCode)
{
throw new System.ApplicationException(
$"TranslatorTextAPI failed. code={response.StatusCode}, message={contentString}");
}
XDocument xdoc = XDocument.Parse(contentString);
string toString = xdoc.Descendants().First().Value;
string[] strs = toString.Split('|');
return new OKDataModel()
{
Caption = strs[0].Trim(),
Category1 = strs[1].Trim(),
Category2 = strs[2].Trim(),
DominantColorForeground = strs[3].Trim(),
DominantColorBackground = strs[4].Trim()
};
}
Translator Text APIからは、他のAzureのコグニティブ系APIとは異なり、本文がXMLで返ってきます。
<string xmlns="http://schemas.microsoft.com/2003/10/Serialization/">屋外 | oceanbeach | 大規模な建物 |グレー |ブラック</string>
なぜか日本語と区切り文字の間に、スペースを入れたり入れなかったりしてきます。このため、Trim()
してスペースを除去しています。
Dialogflowに返却するJSON
以下のような感じでAzure Functionsからレスポンスを送信して、DialogflowでLOCAL_WEBHOOK_RECEIVED
イベントを発生させます。
{
"followupEvent": {
"name": "LOCAL_WEBHOOK_RECEIVED",
"data": {
"category1": "",
"category2": "",
"caption": "花のようにカラフルな蝶",
"dominantColorForeground": "グリーン",
"dominantColorBackground": "グリーン"
}
}
}
Dialogflowインテント: Query.Response
LOCAL_WEBHOOK_RECEIVED
イベントを受けて処理するインテントを作成します。イベントから返されたdata
の内容をインテントの変数に設定し、その変数を使った応答文を登録しておきます。Dialogflowは登録された応答文の中からランダムに選択して、ユーザに返します。
-
Contexts
- Output Contextに
CONTEXT_REVENGE
を追加
- Output Contextに
-
Events
LOCAL_WEBHOOK_RECEIVED
-
Action
- PARAMETER NAME:
category1
(category2
,caption
,dominantColorForeground
,dominantColorBackground
も同様) - ENTITY:
@sys.any
- VALUE:
#LOCAL_WEBHOOK_RECEIVED.category1
- PARAMETER NAME:
-
Response
$category1のようだね。$category2かな?
$dominantColorBackground地に$dominantColorForegroundだね。
$category1のようだね。なんだか$dominantColorBackgroundっぽいなあ。
$captionのようだね。$dominantColorForegroundがぴりっとしてる。
マニュアルには特に明記がありませんが、応答文は、アサイン可能な変数が多く含まれているものが優先的に選択されるようです。上記の例では、すべての応答文に、変数を2個入れています。変数が1個しかなかったり2個あったりする応答文を登録してしまうと、変数が1個の応答文が採用される確率は極めて低くなりますので、ご注意を。蝶々の例では
$category1
と$category2
には値が設定されませんが、その場合は2番目か4番目の文のどちらかが採用されることになります。
「ユーザのコメントに反応する」実装
さて、ボットから画像に対する感想を受けて、ユーザが何かコメントを発しました。「そうだね」「よくできました」的な賛同かもしれないし、「そうじゃないんだけど」的な文句かもしれません。今回のボットは、ユーザの文言に一々反応しません。ユーザのコメントの「雰囲気」を感じ取ります。良い感触だったら素直に喜んで、嫌な空気を感じ取ったら、ちょっと下手に出ながらもう一度感想を言ってみます。
Dialogflowインテント: Reaction
ボットの解析結果を受け取ってからユーザが何か反応した場合にだけ呼び出されるインテントとして作成します。Query.Responseインテントで、コンテキストにCONTEXT_REVENGE
を追加してユーザに返しました。つまり、ユーザから何か返ってくると、コンテキストにCONTEXT_REVENGE
が含まれているはずです。Input ContextをCONTEXT_REVENGE
に設定して待ち受けていれば、このインテントが呼ばれることになります。
-
Contexts
- Input Contextに
CONTEXT_REVENGE
を追加
- Input Contextに
-
User says
- 入力欄の左端をクリックして
@
に変えてから、@sys.any
と入力
- 入力欄の左端をクリックして
-
Fulfillment
-
Use webhook
にチェックを入れる
-
webhook (Reactionを受ける部分)
先ほどと同じように、Run()
メソッドの中に追加して、インテントがReaction
の場合の処理を追加します。webhookには、このような感じの本文でリクエストが届きます。インテントでは特に変数を設定していないため、入力文章全体が取得できる、result
>resolvedQuery
からユーザのコメントを取得し、感情分析APIに渡しています。
// インテントがReactionか?
else if(intent == "Reaction")
{
// ユーザのコメントを取得する
string comment = (string)data["result"]["resolvedQuery"];
// 無い場合は、エラーとする
if (string.IsNullOrEmpty(comment))
{
throw new System.ArgumentException("comment empty");
}
// 感情分析
double score = await APICaller.MakeSentimentRequestAsync(comment, log);
if (score < 0)
{
// 反応が微妙なようだ
return req.CreateResponse(HttpStatusCode.OK,
new ResponseModel("LOCAL_WEBHOOK_SAD"), "application/json");
}
else
{
// 反応が良いようだ
return req.CreateResponse(HttpStatusCode.OK,
new ResponseModel("LOCAL_WEBHOOK_GLAD"), "application/json");
}
}
感情分析
Natural Language APIのdocuments.analyzeSentimentを呼んで、取得したスコアを数値として呼び出し元に返します。
internal static async Task<double> MakeSentimentRequestAsync(string message, TraceWriter log)
{
// APIキーは、環境変数から取得
string KEY = System.Environment.GetEnvironmentVariable("ST_KEY");
// 環境変数が設定されていない場合はエラー
if (string.IsNullOrEmpty(KEY))
{
throw new System.ArgumentException("ST_KEY is empty");
}
HttpClient client = new HttpClient();
// リクエストURL
string uri = $"https://language.googleapis.com/v1/documents:analyzeSentiment?key={KEY}";
// リクエスト本文
string json = $"{{\"encodingType\": \"UTF8\", \"document\": {{\"type\": \"PLAIN_TEXT\", \"content\": \"{message}\", \"language\": \"ja\"}} }}";
// Computer Vision APIを実行し、レスポンス本文を取得
HttpResponseMessage response = await client.PostAsync(
uri, new StringContent(json, Encoding.UTF8, "application/json"));
JObject rss = await response.Content.ReadAsAsync<JObject>();
// 正常に復帰していなければエラー
if (!response.IsSuccessStatusCode)
{
throw new System.ApplicationException(
$"Google Cloud Natural Language API failed. code={rss["error"]["code"]}, message={rss["error"]["message"]}");
}
// センチメントを取得
return rss["documentSentiment"]["score"].ToObject<double>();
}
Natural Language APIへのリクエスト本文は、このような感じとなります。
{
"encodingType": "UTF8",
"document":
{
"type": "PLAIN_TEXT",
"content": "違うよ!",
"language": "ja"
}
}
それに対するレスポンスは、このような感じとなります。
{
"documentSentiment": {
"magnitude": 0.3,
"score": -0.3
},
"language": "ja",
"sentences": [
{
"text": {
"content": "違うよ!",
"beginOffset": 0
},
"sentiment": {
"magnitude": 0.3,
"score": -0.3
}
}
]
}
Dialogflowに返却するJSON
APIからの結果が負の値であればユーザはご不満のようですので、LOCAL_WEBHOOK_SAD
イベントを発生させるようにAzure FunctionsからJSONを返します。
{
"followupEvent": {
"name": "LOCAL_WEBHOOK_SAD",
"data": {}
}
}
0以上であればLOCAL_WEBHOOK_GLAD
イベントを発生させるように返します。
{
"followupEvent": {
"name": "LOCAL_WEBHOOK_GLAD",
"data": {}
}
}
Dialogflowインテント: Reaction.Sad
webhoook(Azure Functions)により、LOCAL_WEBHOOK_SAD
イベントが発生した時に呼び出されるインテントです。持ち回っているコンテキストCONTEXT_REVENGE
から以前の判定内容を取り出して、再挑戦を試みます。
-
Events
LOCAL_WEBHOOK_SAD
-
Action
- PARAMETER NAME:
category1
(category2
,caption
,dominantColorForeground
,dominantColorBackground
も同様) - ENTITY:
@sys.any
- VALUE:
#CONTEXT_REVENGE.category1
- PARAMETER NAME:
-
Response
ありゃ、そうだったか しょぼん。でも、$dominantColorBackgroundっぽいよね。
あら、そうだったの?$captionとも思ったのよ。
ちょっと見間違っちゃったようね。$category1かな?
それなら$category2かな?
うーん、$dominantColorForegroundが印象に残るんだけど...
降参!
Query.Responseインテントでコンテキストを付与した時に設定した回数(デフォルトでは5回)を越えてコンテキストが消失すると、変数には何も設定されなくなります。変数の設定されていない「降参!」は、この時に採用されて、ユーザに返されます。
Dialogflowインテント: Reaction.Happy
webhoook(Azure Functions)により、LOCAL_WEBHOOK_GLAD
イベントが発生した時に呼び出されるインテントです。会話を終わらせるためにコンテキストをクリアすることさえ気を付ければ、特に問題ないかと思います。
-
Contexts
- Output contextの欄の右端のXをクリックして、
Contexts will be reset
にする
- Output contextの欄の右端のXをクリックして、
-
Events
LOCAL_WEBHOOK_GLAD
-
Response
やっぱりそっかー そうだよね!
ま、そんなところですかね
えへ⭐️
完成したwebhook(Azure Functions)
記事で説明した内容以外に、異常系の処理も入っています。
動作させる際の留意点
Azure Functionsが常時接続でない場合、しばらく間を空けた後にURLを渡すと、webhook呼び出しでエラー(Webhook call failed. Error: Request timeout.
)が発生して、ボットから応答文が返ってきません。少し待ってから、「これ見て」から再度チャレンジすると、うまくいくはずです。Azure Functionsの常時接続をオンにすれば、このような問題は無くなりますが、F1(無料)プランではオンにできませんので、悪しからず。