6
5

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.

ドコモの自然対話開発環境SUNABAでもっと賢いチャットボット作ってみた

Last updated at Posted at 2018-12-16

はじめに

こんにちは。NTTドコモの白水です。

NTTドコモでは,独自に開発している対話型AIサービスのプラットフォームである「自然対話プラットフォーム」の記述言語であるxAIML(エックスエーアイエムエル)の言語仕様と,開発環境SUNABA公開しています

前回は,自然体話プラットフォームを構成する3つの機能のうち,「シナリオ対話」と「意図解釈」を試してみました。今回は残りのひとつ,「サービス連携」を使って,簡単なレストラン検索ボットを作ってみたいと思います。

サービス連携とは
インターネット上の外部サービスと連携する機能です。xAIMLで解決できない処理や,天気などの外部コンテンツを取得する際に利用します。この機能を利用してボットと既存のサービスを連携させ,高機能なボットを作成することができます。

準備

意図解釈のタスク一覧を眺めていたら,グルメ検索のタスクを見つけました。今回は,このグルメ検索タスクと,レストランの検索に便利なぐるなびAPIを利用して,指定したエリアのレストランを検索してくれるボットを作ってみます。

xAIML作成に取り掛かる前に,Pythonを使ってデータの下準備をします。Pythonのバージョンは2.7です。
作成したデータファイルは,自然体話プラットフォームのMAP機能からデータテーブルとして呼び出します。MAP機能はxAIMLの<map>タグで呼び出せる機能で,事前にアップロードされたテーブルファイルを参照して特定文字列の置換を行ってくれます。具体的なファイルのアップロード方法などはマニュアルを参照してください。

ぐるなびのレストラン検索APIでは,検索エリア名をエリアコードに変換する必要があります。本来は,エリアマスタ取得APIを都度コールして最新のエリアコード表を取得するべきなのですが,今回はエリアコードのデータテーブルを事前に作成し,上述したMAP機能を使ってエリア名をエリアコードに変換することにします。
まずは,エリアSマスタ取得APIから,エリアS名とエリアSコードのデータを取得します。このままだと件数が多いので,東京都に属するエリアS名とエリアSコードのみを抽出して,areacode_tokyo.mapというファイルに「エリアS名エリアSコード」となるようにタブ区切りで書き出します。このタブ区切りのファイルが,MAP機能で利用するデータテーブルです。事前に,SUNABAの画面からアップロードしておきましょう。
ついでに,取得したAPIキーも「keyidAPIキー」となるようにkeyid.mapという名で保存してアップロードしておきます。

import codecs
import requests

url = 'https://api.gnavi.co.jp/master/GAreaSmallSearchAPI/v3/'
api_key = 'XXXXX'  # 取得したAPIキー
data = {'keyid': api_key, 'lang': 'ja'}
garea_small = requests.get(url, data).json()
areacode = {}  # 都道府県名とエリア名をキーにした辞書型に変換する
for x in garea_small['garea_small']:
    pref_name = x['pref']['pref_name']
    if pref_name in areacode:
        areacode[x['pref']['pref_name']].update({x['areaname_s']: x['areacode_s']})
    else:
        areacode[x['pref']['pref_name']] = {x['areaname_s']: x['areacode_s']}
# 各行が「エリア名<TAB>エリアコード」のファイルを作成
with codecs.open('areacode_tokyo.map', 'w', 'utf-8') as wf:
    wf.write('\n'.join(['\t'.join([k, v]) for k, v in areacode[u'東京都'].items()]))

これで下準備が整いました。

ボットの作成

意図解釈部分の実装

意図解釈とは,ユーザ発話からユーザが求めているタスクを判定し,そのタスクに必要な情報の抽出を行う技術です。機械学習モデルにより,ユーザ発話を適切なタスク(乗換案内や天気検索など)に振り分け,<pattern>文を実行してくれます。
下に載せたのは,グルメ検索タスクのミニマルな実装例です。<pattern label="ST003003002"/>のように,label属性が使われている部分が意図解釈部分です。例えば,「グルメ検索して」のように,グルメ検索タスクだということは分かるが検索語が分からないユーザ発話は,ST003003002(ジャンル要求コマンド)のラベルが貼られた<pattern>に自動的に振り分けられます。また,「ハンバーグのグルメ検索」のように,グルメ検索タスクだと分かるし検索語も分かるユーザ発話の場合はST003003003(検索開始コマンド)のラベルが貼られた<pattern>に自動的に振り分けられます。

<category>
    <pattern label="ST003003002"/>
    <template>
      <think>
          <set name="_task_keep_dialog_status">true</set>
      </think>
      食べ物のジャンルは何ですか?  <!-- 検索語が分からないときの聞き返し -->
    </template>
</category>
<category>
    <pattern label="ST003003003"/>
    <template>
      <think>
          <set name="_task_keep_dialog_status">true</set>
      </think>
      <get name="_task_slot_gourmetGenre"/>を検索しますか?  <!-- 検索実行の確認 -->
    </template>
</category>
<category>
    <pattern label="ST003003004"/>
    <template>
      調べました。  <!-- 検索結果の返却 -->
    </template>
</category>
<category>
    <pattern label="ST003003005"/>
    <template>
      終了します。  <!-- 検索の終了 -->
    </template>
</category>

このままでは少し簡素にすぎるので,少し手を加えましょう。

グルメ検索タスクにおいて,_task_slot_searchArea(検索エリア)は必須スロットではないので,検索エリアが空白のままでも検索が実行できてしまいます。このままではレストラン検索に不都合ですので,_task_slot_searchAreaにデフォルト値をセットしておくことにします。今回は「赤坂」をセットしました。

<category>
    <pattern label="ST003003002"/>
    <template>
      <think>
          <set name="_task_keep_dialog_status">true</set>
      </think>
      <think><set name="_task_slot_searchArea">赤坂</set></think>  <!-- デフォルト値の設定 -->
      食べ物のジャンルは何ですか?
    </template>
</category>

また,xAIMLの正規化機能により,ユーザ発話中の語が自動的に補完されることがあります(例えば,ユーザ発話中の「赤坂」が「赤坂駅」に変換される等)。今回利用するエリア名に「駅」は入っていないので,このままではパターンマッチに差し支えます。そこで,<li>タグと<mathcer>タグを使って,「_task_slot_searchArea名が『〜駅』なら『駅』より前の部分を_task_slot_searchAreaに再代入する」という正規表現に基づいた処理をさせます。
他の解決方法としては,<pattern>文のlevel属性exactsurfaceに指定する方法があります(こちらの方がスマートかもしれません)。

<category>
    <pattern label="ST003003004"/>
    <template>
      <condition name="_task_slot_searchArea">
        <li regex="(.*?)駅">
          <think><set name="_task_slot_searchArea"><matcher group="1"/></set></think>
        </li>
        <li></li>
      </condition>
      調べました。  <!-- 検索結果の返却 -->
    </template>
</category>

意図解釈機能を活用して,ユーザ発話を適切な<pattern>に振り分けられるようになりました。

サービス連携部分の実装

検索結果が「調べました。」だけでは調べたことになりませんので,<!-- 検索結果の返却 -->とコメントアウトしている部分を作り込んでいくことで,ボットにレストラン検索機能を実装してあげます。レストラン検索は,サービス連携機能を使って外部APIを叩くことで実行します。

CGSの利用

CGSは「コンテキスト・ジェネレーション・サーバー」の略で,xAIMLから外部サービスを利用できる機能のことです。xAIMLでは,<ext>タグでCGSを利用することができます。

今回は,自然体話プラットフォームに内蔵されている汎用CGSを利用して,ぐるなびのレストラン検索APIをコールしてみます。汎用CGSは「汎用」と名前についているように,任意の外部サービスにGETリクエストやPOSTリクエストを送信したいときに,汎用的に利用できます。自然対話プラットフォームには,他にも「URLエンコードCGS」「URLデコードCGS」などのCGSがあらかじめ準備されています。

APIに渡す検索クエリはパーセントエンコーディングされている必要があるので,汎用CGSでリクエストを送信する前に,URLエンコードCGSで検索語をエンコードしてあげます。_task_slot_gourmetGenreの中に検索語が入っているので,<get>タグで中身を展開した上でCGSに渡します。

<ext name="urlencoder">  <!-- URLエンコードCGSで食べ物名をエンコードする -->
    <arg name="target"><get name="_task_slot_gourmetGenre"/></arg>
</ext>

エンコードされた文字列は_ext_urlencoder_resultに入ります。これを<get>で展開し,検索語とします。エリア名(_task_slot_searchArea)も,先述したMAP機能を<map>タグで呼び出してエリアコードに変換し,検索エリア名とします。さらに,keyidの取得にもMAP機能を使います。これは,APIキーを.aimlファイル中にハードコーディングしないための処置です(本来の使い方ではない気もしますがMAP機能はこのような使い方もできます)。これらを&記号で連結して最終的な検索クエリとします。なお,文字列のパーセントエンコーディングと同様,&記号などは&amp;のようにエスケープする必要があります。
ちなみに,文字列の連結に演算子は不要で,<get name="***"/>と続けて書けば,連続した文字列として扱われます。Pythonの気分で+記号を入れてしまうと,+を含んだ文字列が反映されてしまいます。
汎用CGSを利用する部分は,最終的に以下のようになります。

<ext name="multicgs">  <!-- 汎用CGSでGETリクエストを送信する -->
    <arg name="request">
        {
        "method": "GET",
        "url": "https://api.gnavi.co.jp/RestSearchAPI/v3/?hit_per_page=50&amp;areacode_s=<map name="areacode_tokyo"><get name="_task_slot_searchArea"/></map>&amp;keyid=<map name="keyid">keyid</map>&amp;freeword=<get name="_ext_urlencoder_result"/>"
        }
    </arg>
</ext>

レスポンスの整形

汎用CGSからのレスポンスを,システム発話として使える形に整えます。

まず,汎用CGSによるリクエスト送信の成否が_ext_multicgs_statusに格納されているので、_ext_multicgs_statusの値にしたがって条件分岐させることで例外処理とします。
ちなみに_ext_multicgs_status200のようなステータスコードではなく,true/falseで返ってきました。

<condition name="_ext_multicgs_status">
  <!-- 汎用CGSからの返却結果が正常のとき -->
  <li value="true">
    <!-- 正常時の処理 -->
  </li>
  <!-- 汎用CGSからの返却結果が異常 (false) のとき -->
  <li>情報の取得に失敗しました。</li>
</condition>

それでは,正常時の処理を記述していきましょう。
レスポンスのJSONは_ext_multicgs_bodyに入っていますので,.setJsonを使って変数に格納します。レストラン情報はresult.restの下に配列として格納されており,result.rest.nameとすれば,配列の先頭要素が得られます。

<think><predstore>result.setJson(<get name="_ext_multicgs_body"/>)</predstore></think>
お店は<predstore>result.rest.name</predstore>がオススメ!<br/>
アピールポイントは「<predstore>result.rest.pr.pr_short</predstore>」だって。<br/>
場所は<predstore>result.rest.address</predstore><br/>
詳細は<predstore>result.rest.url</predstore>にアクセスしてね。

検索結果の先頭要素ばかり返すのでは面白みがないので,生成した擬似乱数に基づいて,結果をランダムで出力することにします。擬似乱数の作り方は,公式ドキュメントのTipsを参照しました。
まず,(レストラン情報の結果を50件取得しているので)0まで49の擬似乱数を生成します。次に,レストラン情報が格納されている配列をキューとみて,0~49回,.shift()でデキューします。最後にresult.rest.nameでキューの先頭要素を取得すれば,配列の中からレストラン情報を無作為に選べたことになります。

<think><set name="_var_ram"><date format="SSS"/></set></think>  <!-- シード(日時)の取得 -->
<think><set name="shift"><calc name="_var_ram" operator="%">50</calc></set></think>  <!-- 生成した擬似乱数を変数にセット -->
<think><set name="_var_count">0</set></think>  <!-- カウンタの初期化 -->
<!-- 擬似乱数 (0-49) の数だけresult.restの要素をshiftする -->
<condition name="_var_count">
 <li><value><get name="shift"/></value></li>  <!-- _var_countとshiftが同値ならbreakする -->
 <li>  <!-- _var_countとshiftが同値でないならカウンタをインクリメントしてキューの先頭要素を削除する -->
   <think><set name="_var_count"><calc name="_var_count" operator="+">1</calc></set></think>
   <think><predstore>result.rest.shift()</predstore></think>
   <loop/>  <!-- <condition name="_var_count"> まで戻る -->
 </li>
</condition>

ボットと対話してみよう

Pythonを使って作成したボットと対話してみます。対話には,アプリID(ボット毎に払い出される固有の文字列)が必要です。取得してない場合は,ユーザ登録APIをコールして取得しておきましょう。

headers = {'Content-Type': 'application/json;charset=UTF-8'}
bot_id = '*****_Test'
data = {
    'bot_id': bot_id,
    'app_kind': 'Test',
    'notification': False
}
url = u'https://sunaba.xaiml.docomo-dialog.com/UserRegistrationServer/users/applications'
response = requests.post(url, headers=headers, data=json.dumps(data))
app_id = response.json()['app_id']  # アプリIDの取得

アプリID (app_id) を取得できたら,対話APIへリクエストを送信してみます。

data = {
    'clientVer': '1.0.4',
    'language': 'ja-JP',
    'bot_id': bot_id,
    'app_id': app_id,
}
url = u'https://sunaba.xaiml.docomo-dialog.com/SpontaneousDialogueServer/dialogue'
# 対話してみる
data['voice_text'] = u'赤坂でおいしい焼肉が食べたいなあ'
response = requests.post(url, headers=headers, data=json.dumps(data))
print response.json()['systemText']['expression']
# 赤坂駅の焼肉を検索しますか?
data['voice_text'] = u'はい'
response = requests.post(url, headers=headers, data=json.dumps(data))
print response.json()['systemText']['expression']
# お店はXXXがオススメ!
# アピールポイントは「XXX。XXX。」だって。
# 場所はXXX!
# 詳細はhttps://r.gnavi.co.jp/XXXXXにアクセスしてね。

voice_text(ユーザ発話)に対して,適切なシステム発話が返ってくるのを確認できました。

おわりに

自然対話プラットフォームの3つ目の機能である「サービス連携」を,開発環境SUNABAから利用してみました。今回,1ユーザとしてボットを作ってみて,発話文から自動的に振り分けてくれる点(意図解釈)と文字列の揺れを吸収してくれる点(文字列正規化)は便利だと思いました。これらの機能をボット作成者が準備するとなるとかなり大変ですが,自然対話プラットフォームではデフォルトの機能としてあらかじめ準備されており,誰でもすぐに使えます。
一方で,xAIMLが配列をインデクシングできない(配列に添え字でアクセスできない)点には注意するべきだと思いました。xAIMLでは,result.rest.nameはPythonでいうresult['rest'][0]['name']を参照します。result['rest'][n]['name']にアクセスしたい場合,n回shift()を呼び出して先頭要素をデキューした後にresult.rest.nameとする必要があります。
他に気を付けなければいけないポイントは,<get>タグや<set>タグ,<li>タグの記法だと思います。<get>タグや<li>タグには,namevalue属性を利用する記法と,子要素としてnamevalue要素を利用する記法の2種類があります。どちらも得られる結果は同じなのですが,例えば変数を展開した上で評価したい場合などは後者の書き方でなければなりません。特に理由がないのであれば,<get><name>var</name></get>のように,<name>要素を使った記法を覚えておいた方が無難だと思います。

<li value="var"></li>  <!-- "var"という文字列を評価してしまう -->
<li><value>var</value></li>  <!-- 上と等価 -->
<li><value><get name="var"/></value></li>  <!-- varを変数として展開した上で評価する -->

最後に,少し長いですが,機能をまとめたxAIMLを載せておきます。

<?xml version="1.0" encoding="UTF-8"?>
<aiml version="xaiml1.0.0" xmlns="http://www.nttdocomo.com/aiml/schema" xmlns:html="http://www.w3.org/1999/xhtml" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.nttdocomo.com/aiml/schema/AIML.xsd">
  <category>
      <pattern label="ST003003002"/>
      <template>
        <think>
            <set name="_task_keep_dialog_status">true</set>
        </think>
        <think><set name="_task_slot_searchArea">赤坂</set></think>
        食べ物のジャンルは何ですか?
      </template>
  </category>
  <category>
      <pattern label="ST003003003"/>
      <template>
        <think>
            <set name="_task_keep_dialog_status">true</set>
        </think>
        <get name="_task_slot_searchArea"/><get name="_task_slot_gourmetGenre"/>を検索しますか?
      </template>
  </category>
  <category>
      <pattern label="ST003003004"/>
      <template>
        <condition name="_task_slot_searchArea">
          <li regex="(.*?)駅">
            <think><set name="_task_slot_searchArea"><matcher group="1"/></set></think>
          </li>
          <li></li>
        </condition>
        <!-- CGSを利用した外部サービスからの情報取得 -->
        <ext name="urlencoder">  <!-- URLエンコードCGSで食べ物名をエンコードする -->
            <arg name="target"><get name="_task_slot_gourmetGenre"/></arg>
        </ext>
        <ext name="multicgs">  <!-- 汎用CGSでGETリクエストを送信する -->
            <arg name="request">
                {
                "method": "GET",
                "url": "https://api.gnavi.co.jp/RestSearchAPI/v3/?hit_per_page=50&amp;areacode_s=<map name="areacode_tokyo"><get name="_task_slot_searchArea"/></map>&amp;keyid=<map name="keyid">keyid</map>&amp;freeword=<get name="_ext_urlencoder_result"/>"
                }
            </arg>
        </ext>
        <!-- 情報の加工・抽出 -->
        <condition name="_ext_multicgs_status">
          <!-- 汎用CGSからの返却結果が正常のとき -->
          <li value="true">
            <!-- 結果をJSON形式でresultに格納 -->
            <think><predstore>result.setJson(<get name="_ext_multicgs_body"/>)</predstore></think>
            <!-- 結果をランダムで返却 -->
            <think><set name="_var_ram"><date format="SSS"/></set></think>  <!-- シード(日時)の取得 -->
            <think><set name="shift"><calc name="_var_ram" operator="%">50</calc></set></think>  <!-- 生成した擬似乱数を変数にセット -->
            <think><set name="_var_count">0</set></think>  <!-- カウンタの初期化 -->
            <!-- 擬似乱数 (0-49) の数だけresult.restの要素をshiftする -->
            <condition name="_var_count">
             <li><value><get name="shift"/></value></li>  <!-- _var_countとshiftが同値ならbreakする -->
             <li>  <!-- _var_countとshiftが同値でないならカウンタをインクリメントしてキューの先頭要素を削除する -->
               <think><set name="_var_count"><calc name="_var_count" operator="+">1</calc></set></think>
               <think><predstore>result.rest.shift()</predstore></think>
               <loop/>  <!-- <condition name="_var_count"> まで戻る -->
             </li>
            </condition>
            <!-- 配列の先頭要素をユーザに表示 -->
            お店は<predstore>result.rest.name</predstore>がオススメ!<br/>
            アピールポイントは「<predstore>result.rest.pr.pr_short</predstore>」だって。<br/>
            場所は<predstore>result.rest.address</predstore><br/>
            詳細は<predstore>result.rest.url</predstore>にアクセスしてね。
          </li>
          <!-- 汎用CGSからの返却結果が異常 (false) のとき -->
          <li>情報の取得に失敗しました。</li>
        </condition>
      </template>
  </category>
  <category>
      <pattern label="ST003003005"/>
      <template>
        終了します。
      </template>
  </category>
</aiml>
6
5
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
6
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?