タスク指向型対話システム（飲食店紹介）を作成

# 1.概要
自然言語処理を勉強しているうちに、AlexaやGoogleアシスタントのような対話システムを作りたいと思うようになりました。いきなりAlexaレベルの対話システムを作るのは困難であるため、まずは簡単な対話システムを作ってみました。

今回作成した対話システムの簡単な要件は以下の通りです。

* 状態遷移を用いたタスク指向型対話システム
*  タスクは飲食店案内（Hotpepper APIを利用）
*  メッセンジャーアプリ「Telegram」で対話する
*  誰でも利用できる（Heorkuを利用）

**【参考書籍】[Pythonでつくる対話システム](https://www.ohmsha.co.jp/book/9784274224799/)**

# 2.目標設定
対話システム、本記事作成にあたっての個人的な目標を記載しておきます。

* 比較的簡単な対話システムを作成し、対話システムの基本知識を身に付ける
* 書籍「[Pythonでつくる対話システム](https://www.ohmsha.co.jp/book/9784274224799/)」の天気情報案内システムを参考にして、飲食店案内システムを作成する

## 今後の目標
* より高度な対話システムの構築（フレームワークに基づくタスク指向型対話システム、非タスク指向型対話システム）

# 3.実行環境
OS：Linux(Ubuntu 18.04LTS)
エディタ：Visual Studio Code
言語：python 3.9.2
メッセンジャーアプリ：[Telegram](https://telegram.org/)
使用API：[Hotpepper API](https://webservice.recruit.co.jp/doc/hotpepper/reference.html)

# 4.どんなアプリが完成したのか？
どんなアプリが完成したのかをスクリーンショットを用いながら紹介したいと思います。実際の対話例は以下の通りです。
今回作成した対話システムは状態遷移を用いたタスク指向型対話システムであるため、システムが主導権を握り対話が進んでいきます。

まず、システムの方から「どこの県の飲食店情報を知りたいのか？」、続いて、「住所情報は必要か？」「価格情報は必要か？」「何店舗表示するのか？」を聞いてきます。ユーザーはそれぞれの質問に順番に答えていきます。

そして、システムは最後の質問を終えるとユーザーが入力した情報通りに結果を出力します。
以上が、今回作成した対話システムです。

![Dialogue_example1.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1806339/a9422d64-97d1-83c1-4d22-4ef22d4991f7.jpeg)

# 5.対話システムの設計イメージ
対話システム全体の設計イメージは以下の通りです。
**【参考書籍】[Pythonでつくる対話システム](https://www.ohmsha.co.jp/book/9784274224799/)**

TelegramBotクラスは対話システム（RestaurantSystem）のクラスのインスタンスを引数として受け取ります。そして初期発話をする場合にはinitial_messageメソッド、応答する場合にはreplyメソッドを呼び出します。その結果、ユーザーの入力に対応した応答を受け取ることができます。そして、Telegramプラットフォームを介してユーザーとやり取りを行います。

![RestaurantSystem.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1806339/e89c08bc-67fe-d095-450f-02fa8cee454d.jpeg)


# 6.作成フロー
①Telegramを動かすプログラムを作成
②State Chart XML (SCXML)という言語を用いて、状態遷移を実現するプログラムを作成
③飲食店紹介システムのプログラム作成
④作成した対話システムをデプロイ（Heroku）

# 7.各フローの説明
それでは、上記のフローについて順を追って説明していきます。

## ① Telegramを動かすプログラムを作成
### 前準備
* Telegramのインストール（Windows, macOS, スマートフォンどれでも良し）
* Telegram上でBotの作成
* アクセストークンを取得

**【参考記事】[TelegramとPythonを使った初心者向けチャットボットの作り方](https://spjai.com/telegram-python-bot/)**

### プログラム作成
* 必要なモジュールのインポート

```php3
from telegram.ext import Updater, CommandHandler, MessageHandler, Filters
import os
```

* TelegramBotクラスの作成

まずは__init__メソッドです。ここで実際に応答内容の決定を行うsystem（本記事におけるRestaurantSystem）を受け取ります。

```php3
class TelegramBot:
    def __init__(self, system):
        self.system = system
```

続いて__start__メソッドです。このメソッドは対話開始時に呼ばれます。ここで、ユーザーの発話（utt）と情報（sessionID）を渡すための__input__を作成します。そして、システムの最初の発話をinitial_messageから取得し，送信します。

```php3
def start(self, bot, update):
        # 辞書型 inputにユーザIDを設定
        # 'utt' : ユーザの発話
        # 'sessionId' : ユーザを区別するためのID 
        input = {'utt': None, 'sessionId': str(update.message.from_user.id)}
 
        # システムからの最初の発話をinitial_messageから取得し，送信
        update.message.reply_text(self.system.initial_message(input)["utt"])
```

続いて__message__メソッドです。ここでは__reply__メソッドで__input__からシステムの発話を生成します。また、ユーザーから「何店舗の飲食店を表示するのか？」という情報を受け取るため、ユーザーから受け取った数字を__num__に格納します。そして１メッセージにつき１店舗の情報が表示されるようにします。

```php3
def message(self, bot, update):
        # 辞書型 inputにユーザからの発話とユーザIDを設定
        input = {'utt': update.message.text, 'sessionId': str(update.message.from_user.id)}
        # replyメソッドによりinputから発話を生成
        system_output= self.system.reply(input)
        reply = system_output["utt"]
        num = system_output["num"]
    
        # 発話を送信(1店舗ごとにメッセージを送信)
        if len(reply) == num:
            for i in range(num):
                update.message.reply_text(reply[i])
        else:
            update.message.reply_text(system_output["utt"])
```

続いて__run__メソッドです。このメソッドを実行することでTelegramプラットフォームとの通信を開始することができます。webhookの箇所はWebで公開する場合に必要なコードになります。ローカル環境のみで動かす場合であればここのコードは必要ありません。

```php3
def run(self):
        # 実行することでTelegramとの通信を開始する
        updater = Updater(TOKEN) # アクセストークンをセット

        # 対話システムを動かすためのおまじない
        dp = updater.dispatcher
        dp.add_handler(CommandHandler("start", self.start))
        dp.add_handler(MessageHandler(Filters.text, self.message))
         # Start the Bot
        updater.start_webhook(listen="0.0.0.0",
                          port=int(PORT),
                          url_path=TOKEN)
        updater.bot.setWebhook('https://自身のアプリ名.herokuapp.com/' + TOKEN)
        updater.idle()
```

## ②State Chart XML (SCXML)という言語を用いて、状態遷移を実現するプログラムを作成

状態遷移に基づく対話システムでは、状態に応じた発話を行い、次の状態に遷移していきます。状態１⇒状態２⇒状態３...といった流れです。本記事において、状態に応じた発話というのはユーザーに対する質問ということになります。状態１では「どこの県の飲食店情報を知りたいのか？」、状態２であれば「住所情報は必要か？」といった質問になります。このようにして状態を遷移していくことで飲食店紹介のために必要な情報をすべて取得することが出来ます。そして、取得した情報をもとに結果（飲食店）をユーザーに伝えます。

### 本システムにおける状態遷移図
本記事で作成する飲食店紹介システムの状態遷移図は以下のようになります。
![RestaurantSystem1.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1806339/44ffdd7f-d871-1b4c-fcef-c12f25a057b7.jpeg)

### プログラム作成
上記の状態遷移を実現するプログラムは以下の通りです。

```php3
<?xml version="1.0" encoding="UTF-8"?>
<scxml xmlns="http://www.w3.org/2005/07/scxml" version="1.0" initial="ask_pref">
  <state id="ask_pref">			      
    <transition event="pref" target="ask_place_info"/>
  </state>
  <state id="ask_place_info">			      
    <transition event="place_info" target="ask_price_info"/>
  </state>
  <state id="ask_price_info">			      
    <transition event="price_info" target="ask_num"/>
  </state>
  <state id="ask_num">			      
    <transition event="num" target="tell_info"/>
  </state>
  <final id="tell_info"/>
</scxml>
```

## ③飲食店紹介システムのプログラム作成
それでは、飲食店案内システムのプログラムを作成していきます。

### 前準備
* HotpepperAPIのキーを取得する

**【参考記事】[リクルートWEBサービス](https://webservice.recruit.co.jp/doc/hotpepper/reference.htm)**

### プログラム作成
* モジュールのインポート

```php3
import json
import requests
import urllib.parse
import pprint
import sys
from PySide2 import QtCore, QtScxml
from datetime import datetime, timedelta, time
from telegram.ext import Updater, CommandHandler, MessageHandler, Filters
from telegram_bot_test import TelegramBot
```

* RestaurantSystemクラスの作成

まずは都道府県名のリストを作成します。このリストはユーザーの発話から都道府県を抽出する際に利用します。

```php3
# 都道府県名のリスト
    prefs = ['三重', '京都', '佐賀', '兵庫', '北海道', '千葉', '和歌山', '埼玉', '大分',
            '大阪', '奈良', '宮城', '宮崎', '富山', '山口', '山形', '山梨', '岐阜', '岡山',
            '岩手', '島根', '広島', '徳島', '愛媛', '愛知', '新潟', '東京',
            '栃木', '沖縄', '滋賀', '熊本', '石川', '神奈川', '福井', '福岡', '福島', '秋田',
            '群馬', '茨城', '長崎', '長野', '青森', '静岡', '香川', '高知', '鳥取', '鹿児島'] 
```

今回、HotpepperAPIを利用する際に各県の緯度・経度が必要となります。そのため、各県の緯度と経度を格納した辞書を作成します。

```php3
# 都道府県名から緯度と経度を取得するための辞書
    latlondic = {'北海道': (43.06, 141.35), '青森': (40.82, 140.74), '岩手': (39.7, 141.15), '宮城': (38.27, 140.87),
                '秋田': (39.72, 140.1), '山形': (38.24, 140.36), '福島': (37.75, 140.47), '茨城': (36.34, 140.45),
                '栃木': (36.57, 139.88), '群馬': (36.39, 139.06), '埼玉': (35.86, 139.65), '千葉': (35.61, 140.12),
                '東京': (35.69, 139.69), '神奈川': (35.45, 139.64), '新潟': (37.9, 139.02), '富山': (36.7, 137.21),
                '石川': (36.59, 136.63), '福井': (36.07, 136.22), '山梨': (35.66, 138.57), '長野': (36.65, 138.18),
                '岐阜': (35.39, 136.72), '静岡': (34.98, 138.38), '愛知': (35.18, 136.91), '三重': (34.73, 136.51),
                '滋賀': (35.0, 135.87), '京都': (35.02, 135.76), '大阪': (34.69, 135.52), '兵庫': (34.69, 135.18),
                '奈良': (34.69, 135.83), '和歌山': (34.23, 135.17), '鳥取': (35.5, 134.24), '島根': (35.47, 133.05),
                '岡山': (34.66, 133.93), '広島': (34.4, 132.46), '山口': (34.19, 131.47), '徳島': (34.07, 134.56),
                '香川': (34.34, 134.04), '愛媛': (33.84, 132.77), '高知': (33.56, 133.53), '福岡': (33.61, 130.42),
                '佐賀': (33.25, 130.3), '長崎': (32.74, 129.87), '熊本': (32.79, 130.74), '大分': (33.24, 131.61),
                '宮崎': (31.91, 131.42), '鹿児島': (31.56, 130.56), '沖縄': (26.21, 127.68)}
```

本システムでは表示する飲食店数をユーザーから受け取ります。今回は１～１０件までの表示件数に対応するため、１～１０のリストを作成します。このリストはユーザーの発話から１～１０の数字を抽出する際に利用します。

```php3
# 数字のリスト
    nums = ["1","2","3","4","5","6","7","8","9","10"] 
```

次は各状態におけるシステムの発話内容の辞書を作成します。この辞書を利用することで各状態に応じた発話ができるようになります。この辞書のキーの値とSCXMLプログラムで記述したidが一致していることが分かると思います。

```php3
# 状態とシステム発話を紐づけた辞書
    uttdic = {"ask_pref"         : "どこの県の情報を知りたいですか？",
              "ask_place_info"   : "住所情報を表示しますか？",
              "ask_num"          : "何店舗表示しますか(最大10件)？",
              "ask_price_info"   : "価格情報を表示しますか？"}
```

次は、__init__メソッドです。ここでは、対話セッションを管理するための辞書を定義します。

```php3
def __init__(self):
        app = QtCore.QCoreApplication()
        # 対話セッションを管理するための辞書
        self.sessiondic = {}
```

次に、ユーザーの発話から都道府県を抽出する関数（get_pref）、数字を抽出する関数（get_num）、「はい」を抽出する関数(get_place_info, get_price_info)を作成します。本システムにおいて、住所情報・価格情報を表示する条件はユーザーが「はい」と答えた場合のみです。そのため、「はい」という文字列を抽出する関数を用意しました。

```php3
def __init__(self):
        # Qtに関するおまじない
        app = QtCore.QCoreApplication()

        # 対話セッションを管理するための辞書
        self.sessiondic = {}
    
    # テキストから都道府県名を抽出する関数．見つからない場合は空文字を返す．
    def get_pref(self, text):
        for pref in self.prefs:
            if pref in text:
                return pref
        return ""
    
    # テキストから数字を抽出する関数．見つからない場合は空文字を返す．
    def get_num(self, text):
        for num in self.nums:
            if num in text:
                return num
        return ""

    def get_place_info(self, text):
        if "はい" in text:
            return "はい"
        return ""
    
    def get_price_info(self, text):
        if "はい" in text:
            return "はい"
        return ""
```

次はHotpepper API を用いて県の緯度・経度から飲食店の情報を取得する関数を作成します。
**【参考記事】[ぐるなびAPIとホットペッパーAPIで個室のある飲み屋さんをPythonで一覧表示](https://qiita.com/komugi_papa/items/e8af6b9ba674b6cabf7f)**

```php3
def HotpepperAPI(self, lat, lon):
        api_key="自身で取得したAPI keyを入力"
        api = "http://webservice.recruit.co.jp/hotpepper/gourmet/v1/?" \
                "key={key}&lat={lat}&lng={lon}&free_drink=1&private_room=1&course=1range=2&count=100&order=1&format=json"
        url=api.format(key=api_key,lat=lat, lon = lon)
        response = requests.get(url)
        result_list = json.loads(response.text)['results']['shop']
        shop_datas=[]
        for shop_data in result_list:
            # print(shop_data)
            # break
            shop_datas.append([shop_data["name"],shop_data["address"],shop_data["urls"]['pc'], shop_data["budget"]['average'], shop_data["access"], 'Hotpepper'])
        return shop_datas
```

関数を実行することで得られるリスト（shop_datas）は以下のようになります。（一部抜粋）
今回は、店名、住所、ホットペッパーのURL、価格情報、アクセス情報、媒体の情報を取得するようにしました。ここで得られた情報に基づいてユーザーに結果を返すようになります。
HotpepperAPIでは上記の情報以外にも様々な情報を取得することが出来ます。
**【参考記事】[リクルートWEBサービス](https://webservice.recruit.co.jp/doc/hotpepper/reference.html)**


```
[['ぼっちり 高知',
  '高知県高知市本町４－１－５１',
  'https://www.hotpepper.jp/strJ001050442/?vos=nhppalsa000016',
  '【通常】3500円／【宴会】4000円',
  '土電『高知城前』電停から徒歩30秒／ひろめ市場から徒歩2分',
  'Hotpepper'],
 ['ラ ヴィータ',
  '高知県高知市本町３丁目３番地１号',
  'https://www.hotpepper.jp/strJ001217473/?vos=nhppalsa000016',
  '1000円(ランチ)\u30005000円(ディナー)',
  'とさでん『大橋通り』電停徒歩1分／JR高知駅から車で約5分／高知自動車道『高知インター』から車で約25分 ',
  'Hotpepper'],
 ['土佐の地魚 魚翔',
  '高知県高知市帯屋町２-2-20',
  'https://www.hotpepper.jp/strJ001256677/?vos=nhppalsa000016',
  '通常2000円/宴会4000円',
  'とさでん交通伊野線 大橋通駅 徒歩3分 ',
  'Hotpepper'],
 ['ユルギヤ 正ナカ',
  '高知県高知市本町２－１－２１ パームスビル３Ｆ・４Ｆ',
  'https://www.hotpepper.jp/strJ000650435/?vos=nhppalsa000016',
  '4000円\u3000■飲み放題コース4000円～\u3000■二次会コース3000円\u3000',
  'とさでん『はりまや橋』から徒歩7分/『大橋通』電停から徒歩1分/ひろめ市場から徒歩2分/おびさんロード/隣にコインＰ',
  'Hotpepper'],
 ['帯や町 商人 あきんど',
  '高知県高知市帯屋町２－１－１４\u3000１F',
  'https://www.hotpepper.jp/strJ001152995/?vos=nhppalsa000016',
  'ランチ：日替り定食680円～／夜：3500円／宴会コース4000円',
  '土電『大橋通』電停から北へ約40m→1つ目の十字路を右折→約20m先左手のビル ',
  'Hotpepper'],
 ['和餐 帯や 勘助',
  '高知県高知市帯屋町２－２－１５',
  'https://www.hotpepper.jp/strJ000039029/?vos=nhppalsa000016',
  '4000円',
  'とさでん『大橋通』電停から徒歩3分／『はりまや橋』電停から徒歩15分／ひろめ市場から徒歩1分',
  'Hotpepper'],
```

続いて、システムの初期発話を定義する関数を作成します。ここでは、初期状態におけるシステムの発話を取得します。
返り値は辞書型で以下の通りです。

* 発話内容（__utt__）
* 最後の状態であるかを判定する値（__end__）
* 表示店舗数の値(__num__) 　※初期状態では必要のない要素であるので0に設定しています。

```php3
def initial_message(self, input):
        text = input["utt"]
        sessionId = input["sessionId"]

        self.el  = QtCore.QEventLoop()        

        # SCXMLファイルの読み込み
        sm  = QtScxml.QScxmlStateMachine.fromFile('states_hot.scxml')

        # セッションIDとセッションに関連する情報を格納した辞書
        self.sessiondic[sessionId] = {"statemachine":sm, "pref":"", "place_info":"", "price_info":"", "num":""}

        # 初期状態に遷移
        sm.start()
        self.el.processEvents()

        # 初期状態の取得
        current_state = sm.activeStateNames()[0]
        print("current_state=", current_state)

        # 初期状態に紐づいたシステム発話の取得と出力
        sysutt = self.uttdic[current_state]

        return {"utt":"こちらは飲食店案内システムです。" + sysutt, "end":False, "num": 0}
```

続いて、応答内容を定義する関数を作成します。ここで、現在の状態に基づいた処理を行います。
県名を聞く状態と表示店舗数を聞く状態において、ユーザーからの回答が正しく入力されていない場合（県名ではない、数字ではない）は、次の状態には進まないようにします。
住所情報・価格情報が必要かどうかを聞く状態では、「はい」以外の入力は全て「情報は必要ない」と判断し次の状態へ遷移するようにしました。そのため、この状態がループすることはありません。

そして、最終状態（__tell_indo__）であれば、ユーザーからの入力に応じた情報を結果として返します。

```php3
def reply(self, input):
        text = input["utt"]
        sessionId = input["sessionId"]

        sm = self.sessiondic[sessionId]["statemachine"]
        current_state = sm.activeStateNames()[0]
        print("current_state=", current_state)

        # ユーザ入力を用いて状態遷移
        # 県名が正しく入力されていない場合、次の状態には進めない
        if current_state == "ask_pref":
            pref = self.get_pref(text)
            if pref != "":
                sm.submitEvent("pref")
                self.el.processEvents()
                self.sessiondic[sessionId]["pref"] = pref

        # "はい"以外の文字であっても、次の状態に進む
        elif current_state == "ask_place_info":
            place_info = self.get_place_info(text)
            sm.submitEvent("place_info")
            self.el.processEvents()
            self.sessiondic[sessionId]["place_info"] = place_info
        
        # "はい"以外の文字であっても、次の状態へ進む
        elif current_state == "ask_price_info":
            price_info = self.get_price_info(text)
            sm.submitEvent("price_info")
            self.el.processEvents()
            self.sessiondic[sessionId]["price_info"] = price_info

        elif current_state == "ask_num":
            num = self.get_num(text)
            if num != "":
                sm.submitEvent("num")
                self.el.processEvents()
                self.sessiondic[sessionId]["num"] = num
        
         # 遷移先の状態を取得
        current_state = sm.activeStateNames()[0]
        print("current_state=", current_state)

        if current_state == "tell_info":
            utt = []
            # utt.append("お伝えします")
            pref = self.sessiondic[sessionId]["pref"]
            place_info = self.sessiondic[sessionId]["place_info"]
            price_info = self.sessiondic[sessionId]["price_info"]
            num = self.sessiondic[sessionId]["num"]
            lat = self.latlondic[pref][0] # placeから緯度を取得
            lon = self.latlondic[pref][1] # placeから経度を取得
            # print("lat=",lat,"lon=",lon)
            sd = self.HotpepperAPI(lat, lon)

            for i in range(int(num)): # 入力された件数分の店舗情報を出力
                utts = []
                utts.append("店名：" + str(sd[i][0] ))
                if place_info == "はい": # "はい"以外の返事であれば、住所情報は表示しない
                    utts.append("住所：" + sd[i][1] )
                if price_info == "はい": # "はい"以外の返事であれば、価格情報は表示しない
                    utts.append("価格：" + sd[i][3] )
                utts.append("アクセス情報：" + sd[i][4])
                text = "\n".join(utts)
                utt.append(text)
            # pprint.pprint(utt)
            # print("test:", utt[1])
            del self.sessiondic[sessionId]
            return {"utt": utt, "end": True, "num": int(num)}
        else:
            # その他の遷移先の場合は状態に紐づいたシステム発話を生成
            sysutt = self.uttdic[current_state]
            return {"utt":sysutt, "end": False, "num": 0}
```

最後は実行時の処理を書きます。RestaurantSystemをインスタンス化、そしてTelegramBotクラスを実行します。

```php3
if __name__ == '__main__':
    system = RestaurantSystem()
    bot = TelegramBot(system)
    bot.run()
```

## ④作成した対話システムをデプロイ（Heroku）
ここでは、誰でもこの飲食店紹介システムを使えるようにするためHerokuにデプロイします。
本記事では、飲食店案内システムの作成に焦点を置いているためデプロイまでの方法を記載はしません。
以下の記事を参考にデプロイしてみてください。

**【参考記事①】[TelegramとPythonを使った初心者向けチャットボットの作り方](https://spjai.com/telegram-python-bot/#Heroku)**
**【参考記事②】[【Python】PythonプログラムをHerokuにデプロイする方法](https://qiita.com/1-row/items/80f89c8ada2e61f04446)**
**【参考記事③】[How to Deploy a Telegram Bot using Heroku for FREE](https://towardsdatascience.com/how-to-deploy-a-telegram-bot-using-heroku-for-free-9436f89575d2)**

# 8.全体を通しての振り返り
本記事では、「タスク指向型（飲食店紹介）対話システム」を作成しました。初心者であるため、コードは書籍から参考にした部分が多く、自分で修正したところは不細工な書き方になっているかもしれませんが、一応作成することはできました。ここでは、この作成を通じて得られたこと、課題を記載したいと思います。

【得られたこと】

* 状態遷移に基づくタスク指向型対話システムの仕組みを理解した
* APIを使い方を把握することができた
* Herokuのデプロイ方法を理解した

【課題】

* レビュースコアの高い順番に表示させるなどの工夫が必要（現状ではただの飲食店情報の羅列になっている）

# 9.最後に
今回作成した対話システムはほんの第一歩です。世の中には、より高度な対話を実現しているシステムがたくさんあります。今後、さらに対話システムの勉強を続け、自分でも高度な対話システムが作成できるようになりたいと思います。

ここまで読んでいただきありがとうございました。