1.概要
自然言語処理を勉強しているうちに、AlexaやGoogleアシスタントのような対話システムを作りたいと思うようになりました。いきなりAlexaレベルの対話システムを作るのは困難であるため、まずは簡単な対話システムを作ってみました。
今回作成した対話システムの簡単な要件は以下の通りです。
- 状態遷移を用いたタスク指向型対話システム
- タスクは飲食店案内(Hotpepper APIを利用)
- メッセンジャーアプリ「Telegram」で対話する
- 誰でも利用できる(Heorkuを利用)
【参考書籍】Pythonでつくる対話システム
2.目標設定
対話システム、本記事作成にあたっての個人的な目標を記載しておきます。
- 比較的簡単な対話システムを作成し、対話システムの基本知識を身に付ける
- 書籍「Pythonでつくる対話システム」の天気情報案内システムを参考にして、飲食店案内システムを作成する
今後の目標
- より高度な対話システムの構築(フレームワークに基づくタスク指向型対話システム、非タスク指向型対話システム)
3.実行環境
OS:Linux(Ubuntu 18.04LTS)
エディタ:Visual Studio Code
言語:python 3.9.2
メッセンジャーアプリ:Telegram
使用API:Hotpepper API
4.どんなアプリが完成したのか?
どんなアプリが完成したのかをスクリーンショットを用いながら紹介したいと思います。実際の対話例は以下の通りです。
今回作成した対話システムは状態遷移を用いたタスク指向型対話システムであるため、システムが主導権を握り対話が進んでいきます。
まず、システムの方から「どこの県の飲食店情報を知りたいのか?」、続いて、「住所情報は必要か?」「価格情報は必要か?」「何店舗表示するのか?」を聞いてきます。ユーザーはそれぞれの質問に順番に答えていきます。
そして、システムは最後の質問を終えるとユーザーが入力した情報通りに結果を出力します。
以上が、今回作成した対話システムです。
5.対話システムの設計イメージ
対話システム全体の設計イメージは以下の通りです。
【参考書籍】Pythonでつくる対話システム
TelegramBotクラスは対話システム(RestaurantSystem)のクラスのインスタンスを引数として受け取ります。そして初期発話をする場合にはinitial_messageメソッド、応答する場合にはreplyメソッドを呼び出します。その結果、ユーザーの入力に対応した応答を受け取ることができます。そして、Telegramプラットフォームを介してユーザーとやり取りを行います。
6.作成フロー
①Telegramを動かすプログラムを作成
②State Chart XML (SCXML)という言語を用いて、状態遷移を実現するプログラムを作成
③飲食店紹介システムのプログラム作成
④作成した対話システムをデプロイ(Heroku)
7.各フローの説明
それでは、上記のフローについて順を追って説明していきます。
① Telegramを動かすプログラムを作成
前準備
- Telegramのインストール(Windows, macOS, スマートフォンどれでも良し)
- Telegram上でBotの作成
- アクセストークンを取得
【参考記事】TelegramとPythonを使った初心者向けチャットボットの作り方
プログラム作成
- 必要なモジュールのインポート
from telegram.ext import Updater, CommandHandler, MessageHandler, Filters
import os
- TelegramBotクラスの作成
まずは__init__メソッドです。ここで実際に応答内容の決定を行うsystem(本記事におけるRestaurantSystem)を受け取ります。
class TelegramBot:
def __init__(self, system):
self.system = system
続いて__start__メソッドです。このメソッドは対話開始時に呼ばれます。ここで、ユーザーの発話(utt)と情報(sessionID)を渡すための__input__を作成します。そして、システムの最初の発話をinitial_messageから取得し,送信します。
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__に格納します。そして1メッセージにつき1店舗の情報が表示されるようにします。
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で公開する場合に必要なコードになります。ローカル環境のみで動かす場合であればここのコードは必要ありません。
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)という言語を用いて、状態遷移を実現するプログラムを作成
状態遷移に基づく対話システムでは、状態に応じた発話を行い、次の状態に遷移していきます。状態1⇒状態2⇒状態3...といった流れです。本記事において、状態に応じた発話というのはユーザーに対する質問ということになります。状態1では「どこの県の飲食店情報を知りたいのか?」、状態2であれば「住所情報は必要か?」といった質問になります。このようにして状態を遷移していくことで飲食店紹介のために必要な情報をすべて取得することが出来ます。そして、取得した情報をもとに結果(飲食店)をユーザーに伝えます。
本システムにおける状態遷移図
本記事で作成する飲食店紹介システムの状態遷移図は以下のようになります。
プログラム作成
上記の状態遷移を実現するプログラムは以下の通りです。
<?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サービス
プログラム作成
- モジュールのインポート
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クラスの作成
まずは都道府県名のリストを作成します。このリストはユーザーの発話から都道府県を抽出する際に利用します。
# 都道府県名のリスト
prefs = ['三重', '京都', '佐賀', '兵庫', '北海道', '千葉', '和歌山', '埼玉', '大分',
'大阪', '奈良', '宮城', '宮崎', '富山', '山口', '山形', '山梨', '岐阜', '岡山',
'岩手', '島根', '広島', '徳島', '愛媛', '愛知', '新潟', '東京',
'栃木', '沖縄', '滋賀', '熊本', '石川', '神奈川', '福井', '福岡', '福島', '秋田',
'群馬', '茨城', '長崎', '長野', '青森', '静岡', '香川', '高知', '鳥取', '鹿児島']
今回、HotpepperAPIを利用する際に各県の緯度・経度が必要となります。そのため、各県の緯度と経度を格納した辞書を作成します。
# 都道府県名から緯度と経度を取得するための辞書
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)}
本システムでは表示する飲食店数をユーザーから受け取ります。今回は1~10件までの表示件数に対応するため、1~10のリストを作成します。このリストはユーザーの発話から1~10の数字を抽出する際に利用します。
# 数字のリスト
nums = ["1","2","3","4","5","6","7","8","9","10"]
次は各状態におけるシステムの発話内容の辞書を作成します。この辞書を利用することで各状態に応じた発話ができるようになります。この辞書のキーの値とSCXMLプログラムで記述したidが一致していることが分かると思います。
# 状態とシステム発話を紐づけた辞書
uttdic = {"ask_pref" : "どこの県の情報を知りたいですか?",
"ask_place_info" : "住所情報を表示しますか?",
"ask_num" : "何店舗表示しますか(最大10件)?",
"ask_price_info" : "価格情報を表示しますか?"}
次は、__init__メソッドです。ここでは、対話セッションを管理するための辞書を定義します。
def __init__(self):
app = QtCore.QCoreApplication()
# 対話セッションを管理するための辞書
self.sessiondic = {}
次に、ユーザーの発話から都道府県を抽出する関数(get_pref)、数字を抽出する関数(get_num)、「はい」を抽出する関数(get_place_info, get_price_info)を作成します。本システムにおいて、住所情報・価格情報を表示する条件はユーザーが「はい」と答えた場合のみです。そのため、「はい」という文字列を抽出する関数を用意しました。
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で一覧表示
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サービス
[['ぼっちり 高知',
'高知県高知市本町4-1-51',
'https://www.hotpepper.jp/strJ001050442/?vos=nhppalsa000016',
'【通常】3500円/【宴会】4000円',
'土電『高知城前』電停から徒歩30秒/ひろめ市場から徒歩2分',
'Hotpepper'],
['ラ ヴィータ',
'高知県高知市本町3丁目3番地1号',
'https://www.hotpepper.jp/strJ001217473/?vos=nhppalsa000016',
'1000円(ランチ)\u30005000円(ディナー)',
'とさでん『大橋通り』電停徒歩1分/JR高知駅から車で約5分/高知自動車道『高知インター』から車で約25分 ',
'Hotpepper'],
['土佐の地魚 魚翔',
'高知県高知市帯屋町2-2-20',
'https://www.hotpepper.jp/strJ001256677/?vos=nhppalsa000016',
'通常2000円/宴会4000円',
'とさでん交通伊野線 大橋通駅 徒歩3分 ',
'Hotpepper'],
['ユルギヤ 正ナカ',
'高知県高知市本町2-1-21 パームスビル3F・4F',
'https://www.hotpepper.jp/strJ000650435/?vos=nhppalsa000016',
'4000円\u3000■飲み放題コース4000円~\u3000■二次会コース3000円\u3000',
'とさでん『はりまや橋』から徒歩7分/『大橋通』電停から徒歩1分/ひろめ市場から徒歩2分/おびさんロード/隣にコインP',
'Hotpepper'],
['帯や町 商人 あきんど',
'高知県高知市帯屋町2-1-14\u30001F',
'https://www.hotpepper.jp/strJ001152995/?vos=nhppalsa000016',
'ランチ:日替り定食680円~/夜:3500円/宴会コース4000円',
'土電『大橋通』電停から北へ約40m→1つ目の十字路を右折→約20m先左手のビル ',
'Hotpepper'],
['和餐 帯や 勘助',
'高知県高知市帯屋町2-2-15',
'https://www.hotpepper.jp/strJ000039029/?vos=nhppalsa000016',
'4000円',
'とさでん『大橋通』電停から徒歩3分/『はりまや橋』電停から徒歩15分/ひろめ市場から徒歩1分',
'Hotpepper'],
続いて、システムの初期発話を定義する関数を作成します。ここでは、初期状態におけるシステムの発話を取得します。
返り値は辞書型で以下の通りです。
- 発話内容(utt)
- 最後の状態であるかを判定する値(end)
- 表示店舗数の値(num) ※初期状態では必要のない要素であるので0に設定しています。
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)であれば、ユーザーからの入力に応じた情報を結果として返します。
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クラスを実行します。
if __name__ == '__main__':
system = RestaurantSystem()
bot = TelegramBot(system)
bot.run()
④作成した対話システムをデプロイ(Heroku)
ここでは、誰でもこの飲食店紹介システムを使えるようにするためHerokuにデプロイします。
本記事では、飲食店案内システムの作成に焦点を置いているためデプロイまでの方法を記載はしません。
以下の記事を参考にデプロイしてみてください。
【参考記事①】TelegramとPythonを使った初心者向けチャットボットの作り方
【参考記事②】【Python】PythonプログラムをHerokuにデプロイする方法
【参考記事③】How to Deploy a Telegram Bot using Heroku for FREE
8.全体を通しての振り返り
本記事では、「タスク指向型(飲食店紹介)対話システム」を作成しました。初心者であるため、コードは書籍から参考にした部分が多く、自分で修正したところは不細工な書き方になっているかもしれませんが、一応作成することはできました。ここでは、この作成を通じて得られたこと、課題を記載したいと思います。
【得られたこと】
- 状態遷移に基づくタスク指向型対話システムの仕組みを理解した
- APIを使い方を把握することができた
- Herokuのデプロイ方法を理解した
【課題】
- レビュースコアの高い順番に表示させるなどの工夫が必要(現状ではただの飲食店情報の羅列になっている)
9.最後に
今回作成した対話システムはほんの第一歩です。世の中には、より高度な対話を実現しているシステムがたくさんあります。今後、さらに対話システムの勉強を続け、自分でも高度な対話システムが作成できるようになりたいと思います。
ここまで読んでいただきありがとうございました。