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

[Cobgot!](https://qiita.com/advent-calendar/2017/cogbot)アドベントカレンダーの８日目となります。今日は、[ボット](https://qiita.com/advent-calendar/2017/bot)アドベントカレンダーからお邪魔しています。両方に同一の記事を予約投稿できなかったため、苦肉の策として前半はボットに、後半はCogbot!に投稿しています。

## ①概要編

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

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

![architecture.png](https://qiita-image-store.s3.amazonaws.com/0/10290/49c2ed50-1fe7-b3f1-5bcf-474269040212.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として登録されたプログラムに、[このような感じの本文で](https://gist.github.com/Makopo/59ab0cc9cd71999ff9f0a4583713bbf9)リクエストを送信してきます。Azure Functionsで、このような形のリクエストを適切に処理できるように実装します。

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

```csharp: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");
}

```
> 本来であれば`parameters`の`url`からURLを取得すべきですが、以下の例のようにスラッシュが空白に変換された状態で入ってしまうので、止むを得ず`result`>`resolvedQuery`から取得するようにしています。[@sys.anyに設定した値が変更されてしまう事例は、他にも報告されているようです](https://discuss.api.ai/t/sys-any-removing-special-characters/10869)。
>
> ```json
> "parameters": {
      "url": "https cdn pixabay com photo 2016/05/14/12/58 butterfly-1391809 960 720 jpg"
    },
> ```

#### 画像解析

上で呼び出している`APICaller.MakeAnalysisRequestAsync`メソッドの中身です。Computer Vision APIの[Analyze Image](https://westus.dev.cognitive.microsoft.com/docs/services/56f91f2d778daf23d8ec6739/operations/56f91f2e778daf14a499e1fa)を呼び出します。[このような感じの本文で](https://gist.github.com/Makopo/ab0adbb3bfe3850c64e616ed41dfbd61)レスポンスが返ってきますので、会話のネタになりそうで、ハズレが比較的少ない、以下を抽出します。

* カテゴリ1 (`A_B`の`A`の部分)
* カテゴリ2 (`A_B`の`B`の部分)
* キャプション
* 前景色で一番目立つ色
* 背景色で一番目立つ色

```csharp: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();
        // 「カテゴリ１_カテゴリ２」になっているのを分解
        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セクションが無いこともありますので、気をつけてください([蝶々の例](https://gist.github.com/Makopo/52eb628eb31c17f7f3597cae2ec29389))。

#### (環境変数の利用)

各種APIのサブスクリプションキーやリージョン名が含まれるエンドポイントURLは、ソースに直接書くと、なんだか危険な気がしますので、Azure Functionsの環境変数に定義します。

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

![local.png](https://qiita-image-store.s3.amazonaws.com/0/10290/54d90aa5-e6ec-f6f9-ad8a-fd965dcd27fa.png)

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

![スクリーンショット 2017-12-02 18.57.44.png](https://qiita-image-store.s3.amazonaws.com/0/10290/aa9aec7d-a4eb-ada6-ecb6-92fccac63d02.png)

#### 翻訳

2017年12月時点でComputer Vision APIは日本語に対応していないため、結果を日本語に翻訳する必要があります。[Translator Text API](http://docs.microsofttranslator.com/text-translate.html#!/default/get_Translate)を使います。

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

```csharp: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で返ってきます。

```xml:レスポンスの例
<string xmlns="http://schemas.microsoft.com/2003/10/Serialization/">屋外 | oceanbeach | 大規模な建物 |グレー |ブラック</string>
```

なぜか日本語と区切り文字の間に、スペースを入れたり入れなかったりしてきます。このため、`Trim()`してスペースを除去しています。

#### Dialogflowに返却するJSON

以下のような感じでAzure Functionsからレスポンスを送信して、Dialogflowで`LOCAL_WEBHOOK_RECEIVED`イベントを発生させます。

```json:レスポンスの例
{
    "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には、[このような感じの本文で](https://gist.github.com/Makopo/652cad13b93845f657915d12ffc54430)リクエストが届きます。インテントでは特に変数を設定していないため、入力文章全体が取得できる、`result`>`resolvedQuery`からユーザのコメントを取得し、感情分析APIに渡しています。

```csharp: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](https://cloud.google.com/natural-language/docs/reference/rest/v1/documents/analyzeSentiment?hl=ja)を呼んで、取得したスコアを数値として呼び出し元に返します。

```csharp: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へのリクエスト本文は、このような感じとなります。

```json:リクエスト本文の例
{
  "encodingType": "UTF8",
  "document": 
  {
    "type": "PLAIN_TEXT",
    "content": "違うよ！",
    "language": "ja"
  }
}
```

それに対するレスポンスは、このような感じとなります。

```json:レスポンス本文の例
{
    "documentSentiment": {
        "magnitude": 0.3,
        "score": -0.3
    },
    "language": "ja",
    "sentences": [
        {
            "text": {
                "content": "違うよ！",
                "beginOffset": 0
            },
            "sentiment": {
                "magnitude": 0.3,
                "score": -0.3
            }
        }
    ]
}
```

#### Dialogflowに返却するJSON

APIからの結果が[負の値であればユーザはご不満のようですので](https://cloud.google.com/natural-language/docs/reference/rest/v1/Sentiment?hl=ja)、`LOCAL_WEBHOOK_SAD`イベントを発生させるようにAzure FunctionsからJSONを返します。

```json:残念なレスポンスの例
{
    "followupEvent": {
        "name": "LOCAL_WEBHOOK_SAD",
        "data": {}
    }
}
```

0以上であれば`LOCAL_WEBHOOK_GLAD`イベントを発生させるように返します。

```json:喜びのレスポンスの例
{
    "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)

https://gist.github.com/Makopo/ab7c1e20d79043e50672b02f35c12e75

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

## 動作させる際の留意点

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