複雑な会話のできるSlackアプリをつくろう

2019/12/23 3:37 2019/12/23 18:28
Advent Calendar 2019

この記事はKCSアドベントカレンダー2019の23日目です.あとこの記事は2.7万字あります.

22日目|本記事|24日目→

こんにちは.B4のFFです.ただいま,絶賛卒論ラブレターを誠意製作中です.

1. まえおき

ことしのアドベントカレンダーのテーマは「(Yes!)青春と音楽!(Beleave)キズナのモノガタリ!(Dream)純情と情熱!」としました.

この記事で扱うネタは「Botとの会話」.まあ会話するってことは広義の青春てことでしょう.Botといえど会話していくうちに絆が芽生えるかもしれないですし,開発には純情と情熱をもって取り組みました.おお.なんと音楽以外のテーマはこのネタで賄えてしまいました.

2. はじめに

Slackはさまざまな企業や団体で利用されているチャットツールです.特徴として議題ごとにチャンネルという物を作成してそこで業務連絡やら他愛のない話を行えます.また,DMの連絡,スレッド機能といった痒いところに手が届く機能やカスタム絵文字や特定の文字列に機械的に返信するBotの作成といったカスタマイズ性の高いことも魅力の一つです.

(うるさいBotの例)

(魅力的なスタンプの例)

そのカスタマイズの機能の一つとしてカスタムアプリケーションの作成(Slack API)があります(以下Slack appといいます).

Slack appでは以下のような機能が提供されています.

機能名 概要 リンク
Incoming Webhooks 外部からHook URLを叩くとメッセージの送信ができる 公式
Slash Commands /から始まるような命令を受け取って指定したURLにPOSTする 公式
Bot Users Appが対話する際のプロフィール設定などを行う -
Interactive Components ボタンなどの対話的な要素を返信中に含めることができる 公式
Event Subscription Slack内でのイベントを購読して,発火した際に指定したURLにPOSTする 公式
OAuth & Permissions OAuth認証トークン/リダイレクトURLの設定/appのアクセス可能なスコープの設定 公式

それぞれの機能によるリクエストの流れは以下の手書きラブレターの感じになります.


ほかに,App Homeの設定やapp頒布を行うための設定などがありますが,ここでは,あくまで特定のワークスペースでのみ動くSlack appを作成することを目的とします.

3. 設計

アプリを作るにあたり,何をどう作るかせっかくなので決めます.

今回作るアプリは以下の機能を持たせることにします.

  • 会話させたい
    • 他愛もない挨拶に他愛もない挨拶を返す(単純な返信)
    • にゃーんと鳴いたら猫の画像を返す(画像を用いた返信)
    • Todoリスト管理Botをつくろう(DBとWebhookを使って複雑な返信を行う)
      • Firestore

会話させるといった際,日本語の構文解析などいろいろ面倒な事態が生じますがその辺の推論と返信は外部のアプリケーションを利用します.

ここではChatBotをコードレスで設計できるDialogflowを使います.

加えて,Todoリストの保存には何かしらのDBが必要ですが,ここではFirebaseのNoSQL DBであるFirestoreを使います.

4. 実装

4.1. Botの準備

前提としてSlackのワークスペースを持っているものとします.持ってない方は作成するか所属団体に作成の指示を仰いではいかがでしょうか.団体の所属者はapp作成に管理者に許可を仰ぐなどする必要があるかもしれませんが,そのときは頑張ってください.

まずSlack APIにアクセス,ログインして,ヘッダ右部のYour Appsをクリックします.

App Nameにアプリの名前を入力し,Development Slack Workspaceにappを追加させたいワークスペースを選択します.

こんな感じの画面に飛べば成功です.これでSlackの準備は完了なので次はDialogflowのセットアップを行います.

4.2. Dialogflowの準備

DialogflowはGoogleアカウントでサインインできます.トップページヘッダ右上部のGo to consoleをクリックすることでチャットボットの作成を行うことができます.最初のアクセスでは地域とTermに関する同意のダイアログが出てくるかと思いますがよしなにします.

コンソール画面に遷移したら,Create Agentを押し,適当なエージェントの名前を入力します.画面下部には本来言語設定が出るのですが,おそらくフロント側の不具合なのかはじめてAgentを作る際にはDefault Languageの設定が表示されないかもしれません(他環境では未検証)もし画面中央部がずっとロード中の場合には一度リロードしましょう.

Default Languageが表示された場合,Japanese - jaへと変更しましょう.その後Agentを作成します.

Agentとはユーザとの対話相手です.Dialogflowではエンドユーザのある話題に対する応答をIntentを作ることで応対します.また,Dialogflowでは特定の語群に対する語彙を作成できます.これはEntityと呼ばれるものでこれを設定しておけば,

User)「(ファミチキください…)」

Bot)「(こいつ直接脳内に…! ファミチキは食べ物の語彙か…!)」

Bot)「(味はどうしますか…?)」

といった対話をすることができます.

まずは,簡単な挨拶をしたら挨拶を返すIntentを作ることにします.

Dialogflowのコンソールのホームは以下のようになっています.まず,今回はSlack appなので「ようこそ」メッセージは必要ないです.なので,「Default Welcome Intent」を選択[1]して,削除します.

この画面のCreate Intentを押すとIntent作成画面へと遷移します.

それぞれのタブで以下の設定を行います.

タブ名 概要
Contexts 「文脈」の入出力.たとえば「オススメの温泉は?」→「xx温泉」→「どこ?」とかいった前後の文により捉え方が異なるアバウトな問いかけに対応するために使用します
Events ユーザからの問いかけ以外でIntentを発火させたい場合に使います.「ようこそ!」といったメッセージは最たる例です.
Training phrases そのIntentが反応すべき文.これをもとに学習を行う.機械学習ベースのため,全ての表記を考慮する必要はなくアバウトに入力文例を入れていけば良い
Action and Parameters ほかの発言をトリガーさせるフラグを設定できたり,応答や外部Webhookに投げるパラメータ値を設定できたり
Responses 応答
Fullfillment チェックをONにすると外部にWebhookとしてデータを送信できる.

まず初めに「こんにちは」と送ったら「お元気そうで何よりです」と返すように設定します.
やることは単純です.Training phrasesに「こんにちは」,Responsesに「お元気そうで何よりです」.おわり.

Text Responseに複数文を設定するとランダムに一つ選んでくれます.

現状は単純なif文にも満たないBotですが,動作確認のためにここで一旦セーブします.

4.3. SlackとDialogflowとの連携

Intentのセーブが完了したら,とりあえず画面右側の入力ボックスに「こんにちは」と入力し,動作確認をします.

作成と学習がしっかり行われた場合には以下のようになります.

これを実際にSlackからアクセスできるChatbotとするためにはDialogflowの「Integrarion」を利用します.

「Integrarion」を左側のパネルから選ぶと様々なアプリの統合先が出てきます.

Slackにチェックを入れ,さらにクリックしてダイアログを出しましょう.

すると2つのURLが出てくるはずです.この2つのURLをSlackに貼り付けて,Slack側の情報をダイアログの上にあるテキストボックスに貼り付けることで連携が行えます.まずはSlack側のURLをDialogflowにセットしましょう.

まず,URLを貼り付ける前にSlack Botユーザの設定を行います.

Slack appに移動し,「Features」>「Bot Users」から「Add a Bot User」を選択して適当に名前をつけてやります.

次に各種情報をDialogflowに貼り付けます.具体的には「Basic Information」に移動し,ページの少し下部にある「App Credentials」内の情報をDialogflowに貼り付けます.

以下の表に対応するように各種情報をDialogflowに入力させます.

Slack App Credentials Dialogflow Integration
Client ID Slack Client ID
Client Secret Slack Client Secret
Verification Token Slack Verification Token

入力が完了したらSTARTをクリックします.「OAuth URL」「Events Request URL」は適宜控えておきましょう.

次はSlack側にURLを設定します.

まず,Botとユーザが互いにDMで会話するためにはDMの受け取りを監視しDMを受け取ったらアクションを起こさせる必要があります.その設定は「Event Subscriptions」>「Enable Events」>「Subscribe to bot events」に「message.im」を追加することでBotがDMを受け取った際にその内容を上部にある「Request URL」にPOSTすることができるようになります.「Events Request URL」をここに貼り付けましょう.変更後は忘れずにセーブしましょう.

また,Slackからの返信にインタラクティブなボタンを追加する際には「Interactive Components」を有効にして,同様に「Request URL」に「Events Request URL」を貼り付ける必要があります.

「OAuth&Permissions」に移動し,「Redirect URL(s)」内の「Add a new Redirect URL」をクリックして「OAuth URL」を貼り付けます(セーブを忘れずに)。

それらが終わったら準備完了です.「Manage Distribution」から,「Add to Slack」ボタンをクリックして実際にSlack appをワークスペースに追加します.追加が終わると実際にSlackにユーザが追加されています.(DMからユーザ名で検索すると発見できます)

いよいよSlackで動作確認です.「こんにちは」…!

「イエーイ!!!\ぱぱらぱっぱぱー/」

Embedded content: https://youtu.be/tBds8l4iZys?t=385

Slackと連携が完了しました.あとはDialogflowで語彙を増やしてより人間ぽくさせてあげれば完全にChatbotでしょう.

次は少し複雑な会話をしてみることにしましょう.

4.4. 画像やボタンで軽く装飾/Contextで自然な会話

せっかくならSlack独自の返信機能を軽く試してみましょう.

以下のコミュニケーションを実装します.

  • ユーザ「にゃーん」
  • ボット「猫だよ」(画像ポイー)
  • ユーザ「かわいい,ありがとう!」(ボタンでリアクション)
  • ボット「ええんやで」

まずは「にゃーん」を感知させます.猫の語彙を覚えさせるためまずはEntityをcatsという名前で作りましょう.思いつく限り類義語を入れてみます.

つぎに,Intent名はあとでわかりやすい名前にしましょう(この場合はrequests.catなどとジャンルごとに分けるとあとで絞りやすいです).

まずは,思いつく限りの限界の文と応答をまずは入力してみます.

次にSlack独自の返答を作ります.Responsesから「+」をクリックして「Slack」を選びます.

「ADD RESPONSES」から「Image」を選択します.本当はランダムに画像を選定したいのですが,Imageで追加できる画像は1種類のみなのでお気に入りの一枚を投げます.

もっとも,Slackは賢いので画像URLのみを返信した場合でも画像を表示してくれるのですが表示のされ方が微妙に異なります.例えば,以下の2枚(ぱくたそ様より拝借)の画像を以下のように入力した場合

画像1) https://www.pakutaso.com/20160344074post-7250.html

画像2) https://www.pakutaso.com/20160237050post-7012.html

返信は以下のようになります.

大した違いはないので必要に応じて適切な方を選択しましょう.

ともあれ,これで「にゃーん」に対して「リアルにゃーんちゃん」で返しました.次は「感想にゃーんちゃんボタン」を設置して「ええんにゃで」させるIntentを作ります.

まず,「感想にゃーんちゃんボタン」に何かしらの「感想」を載せた場合Dialogflow側にはボタンのラベルがテキストデータとして送られます.要するに「『かわいい』ボタンを押す」ことと「『かわいい』と返信する」ことは同一です.そして「かわいい」という情報は前後の文脈がないと主語がなくて何が可愛いのかわかったもんじゃありません.だからといってユーザにいちいち文脈を指定させればいいかもしれませんが,もっと容易な方法としてIntentがContextを生成してそのContextの元で応答するようなIntextを作成させればいいのです.

  • 「猫ポイ〜」
    • 「カワヨカワヨ」
      • (の文脈か…)「猫って可愛いですよね」(文脈情報あり)
      • 「何が可愛いんですか」(文脈情報なし)

Dialogflowで特定の文脈を作成するためには,「Context」タブの「Add Output Context」からtabで区切って入力させていけばOKです.ただし,Dialogflowには少し便利な機能があってある動作を行えば,このContext生成を自動で行ってくれます.

Intent一覧画面に戻り作成したIntentの右部にカーソルを合わせるとAdd follow-up intentと表示されます.これをクリックしたあと「custom」をクリックすると新たに文脈関係を自動的に生成したintentが生成されます.

生成された「子」側のIntent.Contextsに先ほどのIntentのfollowupが自動的に登録されています.

この容量さえ掴んで仕舞えばあとはかんたんです.今回は「かわいい」「癒された」という2つのFollow-up Intentを作成します.


限界っぷりが露呈しており申し訳ありません.

あとは,猫の画像を投げた後に評価ボタンを作れば完了です.(簡易的な)ボタンは「requests.cat」IntentのResponsesからSlackをクリックしてQuick Repliesを選択することで作成できます.よしなに入力してみましょう.

これで完成です!実際にSlackで会話してみましょう.

いい感じです.いい感じに限界です[2]

5. 閑話休題

という名の自分語り.

c.f. https://keiorogiken.wordpress.com/2019/12/12/distortion/

去る文月の吉日.後輩からのお誘いでiOS Appであるバンドリ! ガールズバンドパーティ! をインストールしました.

ソシャゲ経験あまりないためハマらないだろうと思っていたのですが,音ゲーが思いの外楽しくちょくちょくやっているうちにストーリーを読めばガチャを回せるスターが手に入ると気づき,ストーリーを端から読み進めていった結果見事に大ハマりしてしまいました.瀬田薫と白鷺千聖の少し縺れた幼なじみ関係みたいなのが個人的に特にヤバイので,今回(執筆中の12/15実行中であった)のイベントはヤバイです.ヤバくてストーリー本編みる勇気が湧かないです.

とりあえずアニメ本編を見てください.次に,Roselia,Popin’Party,RAISE A SUILENのライブ映像を見てください.次年5月のライブチケットを買ってください.これで限界オタクの完成です.

卒論執筆の秘訣はRoseliaを聞くことです。ありがとうRoselia。感謝を…

あと,感化されたオタク共が楽器を買い始めて非常に面白いことになっています[3].ついでにとうとうKCSのサークルからガルパ 部分が分離されてCiRCLEになりました.

なかなかの限界ですね.今井 リサ (自分)??Reaper使いの湊友希那???

以上.閑話休題であり,今回のテーマの「(Yes!)青春と音楽!(Beleave)キズナのモノガタリ!(Dream)純情と情熱!」の「音楽」部分でした.

6. Webhookと連携した複雑なレスポンスの作成

ある程度単純なレスポンスならば簡単にコードを一切書かなくとも作成することができてしまいました.しかし,これではできることがごく一部に限られてしまっています.

例えば,今回実装するTodo管理アプリを考えてみましょう.

Todo管理のためには「新規TodoをDBに追加」「Todoリストの呼び出し」「タスクの内容や完了状態の変更」「特定のTodoの削除」といったアクションがとられます.もちろんDialogflowにはそんなに高機能な処理を行うような機能は存在しません.それを実装するためには自然な構文を解釈した後に,その解釈結果に応じて外部の(自作)REST APIサーバにPOST/GET/PUT/DELETE処理を実装して(といってもWebhookからの処理はPOSTのみであるためここは適宜変更の必要がある),その処理内容をDBに保存/反映させる必要があります.

今回はAPIサーバを構築するためにPythonのFlask,DBは有名な(m)BaaSであるFirebaseのうちDB機能であるFirestore,サーバのデプロイ先としてGAE(Google App Engine)を利用します.

6.1. 設計

TODOは以下のデータを持つモデルとします.

  • ID(一意な値)
  • 内容(String)
  • 完了したかどうか(Boolean)
  • 追加日(Datetime)
  • 優先度(0-3の整数)

WebhookによりPOSTのみを受け取るので以下のようにしてCRUDを実装します.

  • POSTされるデータ形式はJSONで以下のような形式とする
    • CREATE: { type: “create”:, text: “hogefuga”, priority: 3 }
    • READ: { type: “search”:, query: { id: -1, text: “*”, check: “*”, uncheck: “*”, priority: -1} }
    • UPDATE: { type: “update”:, id: 1, text: “piyo”, check: “*”, uncheck: “*”, priority: 2}
    • DELETE: { type: “delete”:, id: 1 }

READのqueryパラメータは絞り込み条件の指定としています.-1,"*"がデフォルトであり,それぞれの値に適切な値が入ることでその条件で絞り込みを行うことにします.

UPDATE,DELETEに関してはIDを指定して更新/削除するようにさせましょう.

6.2. REST APIサーバの実装

少々複雑ですのでmodelとcontrollerに分割します.ファイル構成は以下のようにします

.
├── 秘密鍵.json
├── app.yaml
├── conf.py
├── controllers
│   └── todo.py
├── database.py
├── main.py
├── models
│   └── todo.py
└── requirements.txt

秘密鍵.json,app.yamlはGAEにデプロイする際に作成するのでここではスルーします.conf.pyは各種設定値,controllers, modelsはMVCアーキテクチャのMとCに相当します.MVCへの造詣が深くないので間違っているかもしれませんが,DB更新等のビジネスロジックをModelへ分離させます.つぎにAPIに入力されたパラメータを解析し適切に更新を行う処理はControllerに委任しています.apiサーバのためViewに相当するようなモノは存在しませんがmodelを参照し,適切にレスポンスを返すアプリケーションルートのmain.pyがViewのロジックに相当すると言えるのではないでしょうか.

各々のファイルを実装していきましょう.

まず,必要なパッケージ(requirements.txt)は以下です

flask
requests
shortuuid
firebase-admin
google-cloud-firestore
gunicorn

ただし,gunicornはローカルに入れる必要はありません.

さて,database.pyです.

import firebase_admin
from firebase_admin import credentials
from firebase_admin import firestore

import conf


class DataBase(object):
    this = None
    def __new__(cls, secret_key_path, envname, db_name):
        if cls.this == None:
            cred = credentials.Certificate(secret_key_path)
            cls.app = firebase_admin.initialize_app(cred, {
                'projectId': envname,
            })
            cls.client = firestore.client().collection(db_name)
            cls.this = super(DataBase, cls).__new__(cls)
        return cls.this

db = DataBase(conf.DB_SECRET_PATH, conf.FIREBASE_ENVNAME, conf.DB_NAME)

シングルトン実装をしています.

Pythonのシングルトンについてはこちら
シングルトン自体についてはこちら
PythonクライアントでのFirestoreの利用方法はこちらを参照します.

まず,Firebaseのプロジェクトを作成し,Firestoreを本番環境で作成しておきます.collectionは「“todos”」という名前で作成します.

Embedded content: https://youtu.be/UFLvSp4Mh9k?t=131

この動画の2:11-6:05あたりが参考になります.UIが若干異なりますが現状特に問題はありません."todos"コレクション作成時にデータを作成する必要がありますが適当に作ってしまってOKです.

つぎに,こちらを参照に秘密鍵ファイルをローカルにおきます(秘密鍵.json).

conf.pyは以下のように設定します

FIREBASE_ENVNAME = "プロジェクトID" # コンソール画面URLの/project/~~~~/overviewの~~~~部分がIDです.
DB_SECRET_PATH = "./秘密鍵.json"
DB_NAME = "todos"

これでFirestoreの用意は完了です.

このFirestoreを参照してデータの変更を行うModelを作ります.

models/todo.py

from database import db
import shortuuid


class Todo:
    def __init__(self, text, priority, is_done=False, _id=None):
        # 8文字のランダムなIDを作成
        if _id == None:
            self.id = str(shortuuid.ShortUUID().random(8))
        else:
            self.id = _id
        self.text = text
        self.priority = priority
        self.is_done = is_done

    # インスタンスプロパティをFirestoreに反映
    def save(self):
        db.client.document(self.id).set(self.to_dict())

    # インスタンスプロパティを参照にデータの削除
    def delete(self):
        db.client.document(self.id).delete()

    # Dict形式に変換
    def to_dict(self):
        return {
            "id": self.id,
            "text": self.text,
            "priority": self.priority,
            "is_done": self.is_done,
        }

    # 出力形式
    def __repr__(self):
        return f'Todo(text="{self.text}", priority={self.priority}, is_done={self.is_done}) @ {self.id}'

    # 検索
    # 各々の引数はデフォルト値をとっておりデフォルト以外を指定すると絞り込みを行う
    @staticmethod
    def get(_id=-1, text="*", priority=-1, is_done=-1, priority_op="=="):
        chain = []
        if _id != -1:
            chain.append(["id", "==", _id])
        if text != "*":
            chain.append(["text", "==", text])
        if priority != -1:
            chain.append(["priority", priority_op, priority])
        if is_done != -1:
            chain.append(["is_done", "==", is_done])
        docs = db.client
        if len(chain) != 0:
            while chain:
                docs = docs.where(*chain.pop(0))
        docs = docs.stream()
        result = []
        for doc in docs:
            result.append(Todo.from_dict(doc.to_dict()))
        return result

    # DictからTodoインスタンスを作成
    @staticmethod
    def from_dict(src):
        return Todo(src["text"], src["priority"], src["is_done"], src["id"])

DBへの反映を主に実装しています.複雑なアプリケーションなので適切に分離しないと煩雑なことになってしまいます.

次は,main.pyの実装です.ここの設計はお好きなようにしてください.

main.py

from flask import Flask, request, Response
import json

import controllers.todo as todo

app = Flask(__name__)

@app.route('/webhook', methods=["POST"])
def webhook():
    try:
        req = json.loads(request.get_data().decode('utf-8'))
    except json.decoder.JSONDecodeError:
        return Response('failed to load request parameters\n', 400)
    try:
        params = req['queryResult']['parameters']
        _type = params['type']
    except KeyError:
        return Response('parameter "type" not found\n', 400)
    if _type == "create":
        return todo.create(params)
    if _type == "search":
        return todo.search(params)
    if _type == "update":
        return todo.update(params)
    if _type == "delete":
        return todo.delete(params)
    return Response("Working", 200)

if __name__ == "__main__":
    app.run(debug=True)

Dialogflowで設定できる各種パラメータは

{
  "queryResult": {
    "parameters": {
      "key1": value1
      "key2": value2
      "key3": value3
    }
  }
}

のように格納されています.このパラメータ値をcontroller.pyに渡してデータの整形と更新処理といったことをすべてcontrollerに委譲しています.

controllers/todo.pyは長いので部分部分で載せていきます.

from flask import Response
import json

from models.todo import Todo

def make_response(dic):
    return Response(json.dumps(dic, ensure_ascii=False), 200, mimetype='application/json')

def create(params):
    # パラメータのパース
    try:
        text = params['text']
    except KeyError:
        return Response('parameter "text" not found\n', 400)
    try:
        priority = int(params['priority'])
    except KeyError:
        priority = 0
    except ValueError:
        return Response('parameter "priority" cannot interpret as integer\n', 400)
    if priority >= 4 or priority < 0:
        return Response('the value of parameter "priority" must be between 0 and 3\n', 400)
    try:
        is_done = bool(params['is_done'])
    except KeyError:
        is_done = False
    except ValueError:
        return Response('the value of parameter "is_done" must be boolean\n', 400)
    # Todoインスタンスの生成
    todo = Todo(text, priority, is_done)
    # Firestoreに反映!
    todo.save()
    # レスポンスの作成
    restxt = f"新規Todo[{todo.text}](ID: {todo.id})が追加されました"
    resp = {
        "followupEventInput": {
            "name": "TODO_FINISH",
            "parameters": {
                "text": restxt
            }
        }
    }
    return make_response(resp)

コードの大部分が例外処理であり,主部分は非常に単純です.

Dialogflowにレスポンスを返すといった際,様々な値を渡すことができます.しかし,今回使うモノはイベントトリガー名と返り値に相当する変数のみがあれば実装できます.

このレスポンスの言わんとするところは,"TODO_FINISH"というイベントがあると発火するIntentに値textを渡す.といった処理です.このようにすることで,DialogflowとFlask間でデータのやり取りを行うことができます.

次に検索部分です.2つの補助関数を作成してします.

# パラメータを元にTodoインスタンスを返す補助関数
def search_helper(params):
    # パラメータのパース
    try:
        _id = int(params['id'])
    except KeyError:
        _id = -1
    try:
        text = params['text']
    except KeyError:
        text = "*"
    try:
        priority = int(params['priority'])
    except KeyError:
        priority = -1
    except ValueError:
        return Response('parameter "priority" cannot interpret as integer\n', 400)
    if priority >= 4 or priority < -1:
        return Response('the value of parameter "priority" must be between -1 and 3\n', 400)
    try:
        # check, uncheckが'*'であるかどうかからis_doneフラグを決定する
        # 完了 false, 未完了 falseで選択しない (-1)
        # 完了 true , 未完了 true ではここでは完了を選択(1)
        check = params['check'] != "*"
        uncheck = params['uncheck'] != "*"
        if not check and not uncheck:
            is_done = -1
        else:
            is_done = check or not uncheck
    except KeyError:
        is_done = -1
    # インスタンスリストの取得
    result = Todo.get(_id, text, priority, is_done)
    return result
    
# todoの情報をSlackに投げるためのテキストに変換
def todo2resp(todo):
    # 優先度→絵文字化
    pri2emoji = {
        3: ":fire::three::fire:",
        2: ":red_circle::two::red_circle:",
        1: ":yellow_heart::one::yellow_heart:",
        0: ":white_small_square::zero::white_small_square:"
    }
    # 完了未完了→絵文字化
    done2emoji = {
        True: ":ballot_box_with_check:",
        False: ":black_square:"
    }
    return f'{done2emoji[todo.is_done]} {pri2emoji[todo.priority]} `{todo.id}`: {todo.text}'

# 検索を行う
def search(params):
    result = sorted(
        search_helper(params),
        key=lambda todo: (not todo.is_done, - todo.priority)
    )
    # レスポンスの生成
    restxt = [f"{len(result)}件のTODOがあります."]
    restxt.extend([
        todo2resp(todo)
        for todo in result
    ])
    if len(restxt) >= 1:
        resp = {
            "followupEventInput": {
                "name": "TODO_FINISH",
                "parameters": {
                    "text": '\n'.join(restxt)
                }
            }
        }
    else:
        resp = {
            "followupEventInput": {
                "name": "TODO_FINISH",
                "parameters": {
                    "text": "条件に合致するTodoが見つかりませんでした"
                }
            }
        }
    return make_response(resp)

ここでも,データの整形が主なため分量が多いですがやっていることはそこまで難しくありません.

最後に更新/削除部分です.

# idからTodoインスタンスを返す
def ud_helper(params):
    try:
        _id = params['id']
    except KeyError:
        _id = -1
    print(_id)
    result = Todo.get(_id)
    return result

# 更新
def update(params):
    result = ud_helper(params)
    # 見つからなかった場合の処理
    if len(result) == 0:
        resp = {
            "followupEventInput": {
                "name": "TODO_FINISH",
                "parameters": {
                    "text": "そのIDのTODOは見つかりませんでした"
                }
            }
        }
        return make_response(resp)
    data = result[0]
    # デフォルトのままなら更新しないようにする
    if params["text"] != "*":
        data.text = params["text"]
    try:
        check = params['check'] != "*"
        uncheck = params['uncheck'] != "*"
        if not check and not uncheck:
            pass
        else:
            data.is_done = check or not uncheck
    except KeyError:
        pass
    if int(params["priority"]) != -1:
        data.priority = int(params["priority"])
    # 変更したdataをDBに反映させる
    data.save()
    resp = {
        "followupEventInput": {
            "name": "TODO_FINISH",
            "parameters": {
                "text": f"ID: {data.id}のTodoは正常に更新されたよ\n{todo2resp(data)}"
            }
        }
    }
    return make_response(resp)
    
# 削除 受け取るのはIDのみのため非常に簡素
def delete(params):
    result = ud_helper(params)
    if len(result) == 0:
        resp = {
            "followupEventInput": {
                "name": "TODO_FINISH",
                "parameters": {
                    "text": "そのIDのTODOは見つかりませんでした"
                }
            }
        }
        return make_response(resp)
    data = result[0]
    # DBから削除
    data.delete()
    resp = {
        "followupEventInput": {
            "name": "TODO_FINISH",
            "parameters": {
                "text": f"ID: {data.id}のTodoは正常に削除されたよ\n{todo2resp(data)}"
            }
        }
    }
    return make_response(resp)

これでREST APIが一通り完成しました.

まずはローカルでちゃんと動くかを確認しましょう.ターミナルを2つ開き片方でflaskサーバを起動します

$ python main.py

もう片方ではcurlでデータを投げてみます.

追加の場合

$ curl -H "Content-Type: application/json" -d '
{
  "queryResult": {
    "parameters": {
      "type": "create",
      "priority": 2,
      "text": "お風呂に入る"
    }
  }
}' localhost:5000/webhook
# -> {
#       "followupEventInput": {
#         "name": "TODO_FINISH",
#         "parameters": {
#           "text": "新規Todo[お風呂に入る](ID: gAjEPP5J)が追加されました"
#         }
#       }
#     }

検索の場合

$ curl -H "Content-Type: application/json" -d '
{
  "queryResult": {
    "parameters": {
      "type": "search"
    }
  }
}' localhost:5000/webhook
# -> {
#       "followupEventInput": {
#         "name": "TODO_FINISH",
#         "parameters": {
#           "text": "1件のTODOがあります.\n:black_square: :red_circle::two::red_circle: `gAjEPP5J`: お風呂に入る"
#         }
#       }
#     }

更新の場合

$ curl -H "Content-Type: application/json" -d '
{
  "queryResult": {
    "parameters": {
      "type": "update",
      "id": "gAjEPP5J"
      "priority": 3
    }
  }
}' localhost:5000/webhook
# -> {
#       "followupEventInput": {
#         "name": "TODO_FINISH",
#         "parameters": {
#           "text": "ID: gAjEPP5JのTodoは正常に更新されたよ\n:black_square: :fire::three::fire: `gAjEPP5J`: お風呂に入る"
#         }
#       }
#     }

削除の場合

$ curl -H "Content-Type: application/json" -d '
{
  "queryResult": {
    "parameters": {
      "type": "delete",
      "id": "gAjEPP5J"
    }
  }
}' localhost:5000/webhook
# -> {
#       "followupEventInput": {
#         "name": "TODO_FINISH",
#         "parameters": {
#           "text": "ID: gAjEPP5JのTodoは正常に削除されたよ\n:black_square: :fire::three::fire: `gAjEPP5J`: お風呂に入る"
#         }
#       }
#     }

上のようなレスポンスが帰ってくれば成功です.これにてCRUDのインタフェースが完成したので,このインタフェースの形式に合わせてDialogflowがWebhookデータを投げればうまく更新されるようになります.

さて,これを簡単にデプロイするためにGoogle Cloud Platform(GCP)のサービスであるGAEを利用します.

6.3. GAEへデプロイしよう!

GAEへデプロイするためにはコマンドラインでgcloudが使える必要があります.また,GCPのプロジェクトは既に作成済みのものとします[4]

gcloudのインストールは簡単で以下のコマンドを実行すればOKです.基本的に質問はEnter連打でいいですが,非bashユーザの場合リソースファイルの場所を適宜書き換えてインストールします.

$ curl https://sdk.cloud.google.com | bash

Do you want to help improve the Google Cloud SDK (Y/n)?  y
Modify profile to update your $PATH and enable shell command
completion? (Y/n)?  y
Enter a path to an rc file to update, or leave blank to use [/home/test-user/.bashrc]:

$ source ~/.bashrc
$ gcloud version
Google Cloud SDK 273.0.0
beta 2019.05.17
bq 2.0.50
core 2019.12.06
gsutil 4.46

のようにバージョンが表示されればOKです.

次にターミナルでコード群を書いたルートディレクトリで以下のコマンドを実行します.

$ gcloud init
# このようなメッセージが出てくるがEnterを押す.
Your current configuration has been set to: [default]

これらを実行するとブラウザが立ち上がりログイン画面に移動します.GCPを扱うアカウントでログインします.その後ターミナルを見ると

You are logged in as: [your-email@address].

Your current project has been set to: [your-project-name].

というような表示が出てきます.

GAEへアプリをデプロイするためには,アプリのコンフィグをapp.yamlというファイルに書いて

$ gcloud app deploy

と実行するだけでOKです.

その設計図にあたるapp.yamlは最もスペックの低いインスタンスにしています.

runtime: python37
service: default
entrypoint: gunicorn -b :$PORT main:app
instance_class: B1
manual_scaling:
  instances: 1

この記述により,Python3.7のサーバが起動します.インスタンスクラスは最も安価な「B1」を使用しており,サーバ起動のためのエントリーポイントとしてコマンドを指定します.

べつにpython main.pyとしてFlaskのサーバをそのまま起動しても動作はします.

* Environment: production
   WARNING: This is a development server. Do not use it in a production deployment.
   Use a production WSGI server instead.
 * Debug mode: on
 * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
 * Restarting with stat
 * Debugger is active!
 * Debugger PIN: 234-239-231

しかし上のFlaskの出力ログにもある通り,本番環境で使うことはあまり推奨されていません.Python上でアプリケーションを動かすためのインタフェースはWSGI(ウィスキーと発音するらしい)と呼ばれます.この統一化されたプロコトルでサーバとアプリケーション間の通信を行うために,何かしらのWSGIアプリケーションが必要となります.中でもGunicornはエントリーポイントのみが少し異なる程度で,簡単に本番環境にも使用可能なWSGIサーバを起動することができます.

これらを書き終えたら

$ gcloud app deploy

で実際にデプロイします.デプロイが終わったらサーバのURLが出てきますのでそれを控えておきましょう.

7 Dialogflowで複雑な処理を行おう

7.1. Entityを作ろう

まずは特定の語彙を教えるべくEntityを作ります.

以上の9種類を作っています.適宜増やしてみても構いません

それぞれの語彙のうちTODOで使うモノはcheck,create,delete,id,search,todo,uncheck,update(要は猫以外)の8つです.それぞれの語彙は思いつく限り適当に入力しています.以下は一例です.

check

完了,チェック,終えた,終わった,終了

create

作る,作成,作りたい,新規,追加

delete

消す,削除,消したい,消去

id

(後述)

検索,探す,調べる

一覧,表示

見たい

todo

Todo,やること,リスト

uncheck

未完了,外す,終わってない

update

更新,訂正,変更,変え


idは英数字8文字からなる文字列です.Dialogflowでは正規表現ベースでマッチングを行うことができます.そのためには以下のようにidの内容を入力します.

\w:英数字
{8}: 8回続く

7.2. Todoの「作成」

いよいよIntentの作成です.todo.create.requestという内容でIntentを作ります.

Training Phasesには以下のような文が来ることを想定しています.

作成した語彙に合わせてマーカー表示されていれば正常に作成が行われています.まれに(特に数字などでは)間違ったEntityと関連づけてしまう場合があります.その場合にはその誤った箇所を選択すれば訂正用のタブが表示されます.

次は,Action and parametersです.

上の画像のようにします.type以外はTraining Phasesに語彙を入力した際に自動追加されています.(誤ったものも追加されてしまっている場合があるので適宜削除訂正してください)

新たな点としてREQUIREDにチェックが入っており,そのチェック項目のPROMPTS欄に以下の文字列を入れています.

パラメータ名 PROMPTS
create TODOを作りたい際は「Todoの作成」と申してください
text Todoリストを作ります.内容はどうしますか?
priority TODOの優先度を決定します.0-3の範囲で優先度を決めてください.数字が大きいほど高優先度です.

この内容で分かる通り,REQUIREDはIntentの処理を行う上での最低パラメータです.そのパラメータが不足している際にPROMPTSの文が返されて,そのパラメータに入れるべき値を再度ユーザに問いかけることができます.さらにParameterはデフォルトの値も設定できます.パラメータの部分右端をクリックすると「だんご三兄弟アイコン[5]」が表示されるのでそれをクリックすると変更ができます.

Reaponsesには単純に以下の内容で登録します.

優先度$priorityの$textという内容でTODOリストを作成しています...

$から始まる値は,お察しの通りAction and parametersで定義したパラメータがそのまま代入されます.

最後に重要な点です.Fullfilmentにチェックを入れます.

こうすることで,このIntentが処理された際にWebhook処理を投げることができます.

さて,これでやっとこさ「作成」が完了しました.あとの「表示」「変更」「削除」はおおよそ似たような処理でできます.それぞれ以下のように設定していきます

7.3. Todoの「検索」

  • 名: 「todo.search.request」
  • Responses: 「Todoを検索しています…」

Training

Paramete

※NULLは設定なしの意味です

パラメータ名 PROMPTS Default
todo TODOについて検索する場合は「TODO検索」と申してください NULL
priority NULL -1
uncheck NULL *
check NULL *
text NULL *
id NULL -1

何も条件を指定しないとデフォルトの値が呼ばれて,全件のTODOを表示する仕組みです.

7.3. Todoの「更新」

まあ一番厄介です…

  • 名: 「todo.update.request」
  • Responses: 「Todoを更新しています…」

Training

Parameter

パラメータ名 PROMPTS Default
update TODOの変更を行いたい場合には「更新」と申してください NULL
priority NULL -1
uncheck NULL *
check NULL *
text NULL *
id 更新したいTODOのIDと更新する要素を申してください NULL

更新のためには「どの」TODOの「どの要素」かを指定する必要があります.なにも要素が指定されない場合はデフォルトの値が呼ばれてそれに該当する要素は更新が行われなくするという魂胆です.表示と異なってIDは絶対必須であることに注意してください.

7.3. Todoの「削除」

一番簡単です.

  • 名: 「todo.delete.request」
  • Responses: 「Todoを削除しています…」

Training

Parameter

パラメータ名 PROMPTS Default
delete TODOを消したい場合は「削除」と申してください NULL
id 消したいTODOのIDを申してください NULL

idさえあれば削除はできます.

7.4. Webhookとやりとりするには

ここまでのながれをまとめてみましょう.

  1. では青部分を作ってきて7. では黄色部分を主に作っています.黄→青への橋渡しはWebhookにパラメータを載せることで解決できました.最後に必要なことは,GAEでの変更の結果をDialogflowが受け取ってそれをSlackのレスポンスとして返すことです.

幸い,6.ですでに返信内容はサーバ側で全て記述しております!

(以下の命令は再掲)

$ curl -H "Content-Type: application/json" -d '
{
  "queryResult": {
    "parameters": {
      "type": "create",
      "priority": 2,
      "text": "お風呂に入る"
    }
  }
}' localhost:5000/webhook
# -> {
#       "followupEventInput": {
#         "name": "TODO_FINISH",
#         "parameters": {
#           "text": "新規Todo[お風呂に入る](ID: gAjEPP5J)が追加されました"
#         }
#       }
#     }

parametersのtextをレスポンスとして返すことができればOKです.これらのテキストはすべてfollowupEventInput内のパラメータです.これは,nameに相当するイベントを発火させる際に使用します.この場合,TODO_FINISHフラグで喋るIntentがtextというパラメータと共に発火します.

それを解釈し,表示させるためのIntentを作成します.いわゆるレスポンスを受け取るためのIntentです.

名前はtodo.finish.responseとでもしておきましょう.

単に「受け取る」だけなので訓練用の文章は打つ必要はありません.

ただしEventsに6.で定義したフラグ名TODO_FINISHとうちます.これにより,Webhookからの返答をフラグとして文をSlackに返すことができるようになります.

重要なのは,Eventsにパラメータを入れている点そして,parameterに値を入れている点です.parameterのvalueは非常に重要で,#イベント名.パラメータ名と指定することでGAE側で指定したイベント/パラメータを代入することができます.この場合は「$text」にGAEでの応答をそのまま代入しているのでResponseはそのまま応答のみを入れています.

これにより,実装は完了です.SAVEするのを忘れずに終えたら実際にテストしましょう.

7.5. 自然言語の辛いところね

初アプリ…ども…

俺みたいなB4でアドベントカレンダー書いてる腐れ野郎、他に、いますかっていねーか、はは

今日のサークルの会話
3rdシーズンのイニシャルエモい とか あのポテトおいしい とか
ま、それが普通ですわな

かたや俺は電子の砂漠で言語を学習させて、呟くんすわ
it’a true wolrd.狂ってる?それ、誉め言葉ね。

好きな副詞 ゆめゆめ
欲しいベース ESP BTL Roselia Lisa(5万の方はNO)

なんつってる間に23時っすよ(笑) あ~あ、自然言語の辛いとこね、これ

自然言語はフワフワしているため扱いが難しいのです.

そのため,思いも寄らない解釈をしてしまう場合があります.特に全く訓練をしていない状況では全く見当違いの解釈をしてしまう場合があります.

7.4.まで書き終えたらSlackでの応答に対応していることでしょうから,試しに適当にTODOを登録してそのIDを控えてみます.(機械学習ベースのため以下の内容に再現性があるとこちらでは保証できません.)

優先度2で「起きる」というTODOを追加

次に,このTODOの優先度を3にしてみます.

NfXm7x6cを優先度3に更新

優先度を3にするつもりが「優先度3」というTODOになっています.そのため学習の修正を行う必要があります.

詳しい更新方法は本家ページのトレーニングのページを参照して欲しいですが,納得のいく応答をするようなBotを作るためにはこのトレーニングによる機械学習モデルの訂正が必要不可欠です.

ここまで詳細に書いておいていうのもなんですが,自然言語は骨が折れるので頑張ってください.適度に応援します.\がんばれー/

8. 謝辞

星4のおかげでここまで書けました.ありがとう,上原さん.山吹さん.美竹さん.

あと,北沢さん,きてほしかったなあ.


  1. 青い丸にカーソルを合わせると選択できます. ↩︎

  2. 今井リサなのにハロハピマークがついていて申し訳ない ↩︎

  3. エレキベースを買ってしまいました. ↩︎

  4. GCPのプロジェクトの作成はhttps://cloud.google.com/compute/docs/quickstart-linux?hl=ja の始める前にの部分を読むなどして適宜作成してください ↩︎

  5. 説明下手で申し訳ないです. ↩︎