2
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

ネットの画像をネタに小話するボット ②実装編

Last updated at Posted at 2017-12-07

Cobgot!アドベントカレンダーの8日目となります。今日は、ボットアドベントカレンダーからお邪魔しています。両方に同一の記事を予約投稿できなかったため、苦肉の策として前半はボットに、後半はCogbot!に投稿しています。

①概要編

本日(12/8)のボットアドベントカレンダーに投稿した、こちらの記事となります。
https://qiita.com/makopo/items/7f3a8dc6bd931069b9a1

作成しようとしているものの概要とアーキテクチャが書かれていますので、一旦こちらをお読みになってから戻ってきてください。

architecture.png

「画像の感想を述べる」実装

ユーザから「これ見て」的な発言を受け取ったら、「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ちょーだい
  • Fulfillment

    • Use webhookにチェックを入れる

webhook (Queryを受ける部分)

Dialogflowからは、webhookとして登録されたプログラムに、このような感じの本文でリクエストを送信してきます。Azure Functionsで、このような形のリクエストを適切に処理できるように実装します。

Visual Studioで、Azure Functionsを新規作成します。新しいAzure関数...で、Azure Functionsファイル(.cs)を作成します。Http triggerを選択します。Access rightsFunctionのままで結構です。Runメソッドの中を書き換え、intentがQueryの場合は、画像解析APIと翻訳APIを順に呼び出して、結果を返すようにします。

Run()
// リクエスト本文
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");
}

本来であればparametersurlから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_BAの部分)
  • カテゴリ2 (A_BBの部分)
  • キャプション
  • 前景色で一番目立つ色
  • 背景色で一番目立つ色
APICaller#MakeAnalysisRequestAsync()
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の環境変数に定義します。

ローカルでのデバッグ時はプロパティ->デバッグ->環境変数で定義します。

local.png

Azureで動作させる際は、Function Appの名前->アプリケーション設定->アプリケーション設定で定義します。

スクリーンショット 2017-12-02 18.57.44.png

翻訳

2017年12月時点でComputer Vision APIは日本語に対応していないため、結果を日本語に翻訳する必要があります。Translator Text APIを使います。

5個のネタをそれぞれAPIに投げるのは勿体無い気がしますので、絶対にComputer Vision APIから返ってこなさそうな文字、例えば|を区切り文字として結合して、翻訳をお願いしてみます。

APICaller#MakeTranslateRequestAsync()
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を追加
  • Events

    • LOCAL_WEBHOOK_RECEIVED
  • Action

    • PARAMETER NAME: category1(category2, caption, dominantColorForeground, dominantColorBackgroundも同様)
    • ENTITY: @sys.any
    • VALUE: #LOCAL_WEBHOOK_RECEIVED.category1
  • 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を追加
  • User says

    • 入力欄の左端をクリックして@に変えてから、@sys.anyと入力
  • Fulfillment

    • Use webhookにチェックを入れる

webhook (Reactionを受ける部分)

先ほどと同じように、Run()メソッドの中に追加して、インテントがReactionの場合の処理を追加します。webhookには、このような感じの本文でリクエストが届きます。インテントでは特に変数を設定していないため、入力文章全体が取得できる、result>resolvedQueryからユーザのコメントを取得し、感情分析APIに渡しています。

Run()
// インテントが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を呼んで、取得したスコアを数値として呼び出し元に返します。

APICaller#MakeSentimentRequestAsync()
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
  • 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にする
  • Events

    • LOCAL_WEBHOOK_GLAD
  • Response

    • やっぱりそっかー そうだよね!
    • ま、そんなところですかね
    • えへ⭐️

完成したwebhook(Azure Functions)

記事で説明した内容以外に、異常系の処理も入っています。

動作させる際の留意点

Azure Functionsが常時接続でない場合、しばらく間を空けた後にURLを渡すと、webhook呼び出しでエラー(Webhook call failed. Error: Request timeout.)が発生して、ボットから応答文が返ってきません。少し待ってから、「これ見て」から再度チャレンジすると、うまくいくはずです。Azure Functionsの常時接続をオンにすれば、このような問題は無くなりますが、F1(無料)プランではオンにできませんので、悪しからず。

2
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?