[iOS] [Swift] [Watson] DialogとNatural Language Classifier(自然言語分類)を組み合わせてチャットボットアプリを作ってみた

  • 62
    Like
  • 0
    Comment

追記(2016.09.10)

[Watson] Dialog Serviceの廃止について
http://qiita.com/y_some/items/de4cdea1b60d36243c0d

概要

曖昧な問いかけに対して、映画のタイトル、または、出演者を答えてくれるチャットボットを作ってみました。

WatsonDialog.gif

動画はこちら

長くなりますが、複数の技術要素を寄せ集めた成果ですので、あえて記事を分割せず一気に解説します。

技術要素と開発環境

Watson

iOS

  • Xcode7.3
  • Swift2.2
  • Carthage
    • JSQMessagesViewController
    • Alamofire
    • SwiftyJSON
    • SVProgressHUD

その他

DBpedia

シーケンス図

スクリーンショット 2016-07-05 6.49.52.png
サーバーサイド機能を実装して、iOSはUI部分のみにした方がベターかと思います。

Watson Dialogについて

ネットに日本語情報が少ないので、私も試行錯誤中です。

Dialogの事始めは以下の記事を参照。
[Watson] Dialogの利用開始手順と日本語化の設定

Dialogシナリオ

XML Notepad などのXMLエディタがあると捗ります。

<?xml version="1.0" encoding="utf-8"?>
<dialog xsi:noNamespaceSchemaLocation="WatsonDialogDocument_1.1.xsd" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
  <flow>
    <folder label="Main">
      <!--対話の入口-->
      <output id="output_main">
        <prompt selectionType="RANDOM">
          <!--itemからランダムに出力-->
          <item>知りたいのは映画作品ですか?それとも出演者ですか?</item>
        </prompt>
        <getUserInput>
          <!--ユーザの入力を受け付ける-->
          <search ref="folder_menu_top">
            <!--folder_menu_topを検索する-->
          </search>
          <default>
            <!--ユーザ入力を解釈できなかった場合の処理-->
            <output>
              <prompt selectionType="RANDOM">
                <item>すみません。違う表現でもう一度おねがいします。</item>
              </prompt>
              <goto ref="##special_DNR_GET_USER_INPUT_NODE_ID"></goto>
            </output>
          </default>
        </getUserInput>
      </output>
      <output id="output_main_end">
        <prompt selectionType="RANDOM">
          <item>お役に立てたら嬉しいです。</item>
        </prompt>
        <action varName="TITLE" operator="SET_TO_BLANK">
          <!--変数にBLANKをセットする-->
        </action>
        <action varName="STARRING" operator="SET_TO_BLANK" />
        <action varName="WORK" operator="SET_TO_BLANK" />
      </output>
    </folder>
    <folder label="Library">
      <!--対話の主要部分-->
      <folder id="folder_menu_top">
        <input>
          <grammar>
            <!--語彙を解釈する-->
            <!-- *: 任意の文字列の場合にヒット -->
            <!-- $: 文字列を含む場合にヒット -->
            <item>$映画作品</item>
          </grammar>
          <output>
            <goto ref="folder_title">
              <!--folder_titleに飛ぶ-->
            </goto>
          </output>
        </input>
        <input>
          <grammar>
            <item>$出演者</item>
          </grammar>
          <output>
            <goto ref="folder_starring" />
          </output>
        </input>
      </folder>
      <folder id="folder_title">
        <output>
          <prompt selectionType="RANDOM">
            <item>どんな映画でしたか?</item>
          </prompt>
          <getUserInput>
            <output>
              <!--変数に値をセットする(他にもいろいろなactionノードの定義の仕方あり)-->
              <action varName="WORK" operator="SET_TO_USER_INPUT"></action>
              <!--{変数}という書き方で変数値を取得できる-->
              <action varName="TITLE" operator="APPEND">{WORK} </action>
              <prompt selectionType="RANDOM">
                <!--%NAME%をiOS側でNLCのClassに置換する-->
                <item>それは%NAME%ではありませんか?</item>
              </prompt>
              <getUserInput>
                <input>
                  <grammar>
                    <item>$はい</item>
                    <item>$ありがとう</item>
                  </grammar>
                  <output>
                    <goto ref="output_main_end" />
                  </output>
                </input>
                <input>
                  <grammar>
                    <item>$いいえ</item>
                    <item>$他の</item>
                  </grammar>
                  <output>
                    <goto ref="folder_title" />
                  </output>
                </input>
              </getUserInput>
            </output>
          </getUserInput>
        </output>
      </folder>
      <folder id="folder_starring">
        <output>
          <prompt selectionType="RANDOM">
            <item>どんな映画に出演していましたか?</item>
          </prompt>
          <getUserInput>
            <output>
              <action varName="WORK" operator="SET_TO_USER_INPUT"></action>
              <action varName="STARRING" operator="APPEND">{WORK} </action>
              <prompt selectionType="RANDOM">
                <item>それは%NAME%ではありませんか?</item>
              </prompt>
              <getUserInput>
                <input>
                  <grammar>
                    <item>$はい</item>
                    <item>$ありがとう</item>
                  </grammar>
                  <output>
                    <goto ref="output_main_end" />
                  </output>
                </input>
                <input>
                  <grammar>
                    <item>$いいえ</item>
                    <item>$他の</item>
                  </grammar>
                  <output>
                    <goto ref="folder_starring" />
                  </output>
                </input>
              </getUserInput>
            </output>
          </getUserInput>
        </output>
      </folder>
    </folder>
    <folder label="Global" />
    <folder label="Concepts">
      <concept>
        <!--同じ語彙を定義-->
        <grammar>
          <item>出演者</item>
          <item>俳優</item>
          <item>女優</item>
          <item>役者</item>
        </grammar>
      </concept>
      <concept>
        <grammar>
          <item>映画作品</item>
          <item>タイトル</item>
          <item>作品</item>
          <item>名前</item>
        </grammar>
      </concept>
      <concept>
        <grammar>
          <item>ありがとう</item>
          <item>どうも</item>
          <item>サンキュー</item>
          <item>さようなら</item>
          <item>じゃあね</item>
          <item>バイバイ</item>
        </grammar>
      </concept>
      <concept>
        <grammar>
          <item>はい</item>
          <item>YES</item>
          <item>OK</item>
          <item>大丈夫</item>
          <item>いいね</item>
        </grammar>
      </concept>
      <concept>
        <grammar>
          <item>いいえ</item>
          <item>NO</item>
          <item>NG</item>
          <item>ダメ</item>
        </grammar>
      </concept>
      <concept>
        <grammar>
          <item>他の</item>
          <item>違う</item>
        </grammar>
      </concept>
    </folder>
  </flow>
  <constants>
    <var_folder name="Home" />
  </constants>
  <variables>
    <var_folder name="Home">
      <!--変数を定義(TEXT以外にも様々なtypeがある)-->
      <var name="TITLE" type="TEXT" />
      <var name="STARRING" type="TEXT" />
      <var name="WORK" type="TEXT" />
    </var_folder>
  </variables>
  <settings>
    <setting name="AUTOLEARN" type="USER">false</setting>
    <setting name="LANGUAGE" type="USER">ja-JP</setting>
    <setting name="RESPONSETIME" type="USER">-2</setting>
    <setting name="MAXAUTOLEARNITEMS" type="USER">4</setting>
    <setting name="NUMAUTOSETRELATED" type="USER">4</setting>
    <setting name="TIMEZONEID" type="USER">Australia/Sydney</setting>
    <setting name="AUTOSETRELATEDNODEID" type="USER">0</setting>
    <setting name="INPUTMASKTYPE" type="USER">0</setting>
    <setting name="CONCEPTMATCHING" type="USER">1</setting>
    <setting name="PARENT_ACCOUNT">en-us-legacy</setting>
    <setting name="PLATFORM_VERSION">10.1</setting>
    <setting name="USE_TRANSLATIONS">2</setting>
    <setting name="USE_SPELLING_CORRECTIONS">2</setting>
    <setting name="USE_STOP_WORDS">2</setting>
    <setting name="USE_CONCEPTS">3</setting>
    <setting name="ENTITIES_SCOPE">3</setting>
    <setting name="DNR_NODE_ID">-15</setting>
    <setting name="MULTISENT">0</setting>
    <setting name="USER_LOGGING">2</setting>
    <setting name="USE_AUTOMATIC_STOPWORDS_DETECTION">0</setting>
  </settings>
  <specialSettings>
    <specialSetting label="DNR Join Statement">
      <variations />
    </specialSetting>
    <specialSetting label="AutoLearn Statement">
      <variations />
    </specialSetting>
  </specialSettings>
</dialog>

今回は使っていませんが、変数の値によって分岐をさせるための<if>ノードなど、他にも様々なノードがあります。
また、<var>ノードのtypeや、<action>ノードで指定できる操作も様々な種類があります。

現状は日本語のドキュメントがないので、詳細はDialog Service Documentation(英語)を参照するしかないと思います。

APIリクエストパラメータ

公式DocumentのAPI Reference(英語)を参照。

レスポンスサンプル(API Referenceからの転載)

Converse(対話)

{
  "response": [
    "Hi this is Watson"
  ],
  "input": "Hi Hello",
  "conversation_id": 11231,
  "confidence": 1,
  "client_id": 4435
}

conversation_id は対話を一意にするIDで、初回のレスポンスで返却されます。
以降のリクエスト時にその値をセットします。
client_id は conversation_id の中で発言者を一意にするためのID(らしい)です。

Get profile variables(変数の取得)

{
  "client_id": 4435,
  "name_values": [
    {
      "name": "topping",
      "value": "cheese"
    }, {
      "name": "size",
      "value": "large"
    }, {
      "name": "size",
      "value": "large"
    }
  ]
}

Watson Natural Language Classifier (NLC) について

私が参考にさせていただいたのは、以下の記事です。
NLCについては他にもネット上にいろいろ情報があると思います。

自然言語分類器 IBM Watson Natural Language Classifier(前編)
自然言語分類器 IBM Watson Natural Language Classifier(後編)

この記事では、トレーニングデータをcurlコマンドでアップロードするよう記述されていますが、現在はGUIでアップロードできる beta toolkit が提供されており、Bluemixダッシュボードから利用できます。

だたし、beta toolkit は米国南部リージョンのみで提供されていますので、NLCサービスの登録先リージョンに注意が必要です。
(2016年7月5日時点)

DialogのシナリオにNLCを統合することもできるらしいですが、今回はそこまで試している余裕がありませんでした。

トレーニングデータ

Wikipediaからトレーニングデータを作ろうと考えました。
Wikipediaからスクレピイングすることは結構な手間ですが、Wikipediaのデータを二次加工しやすい形で提供してくれるDBpediaというサービスがあります。

DBpediaでは、クエリを投げて、その結果をHTMLやXML、CSVなどのフォーマットで受け取ることができます。

DBpediaのクエリを実行するフォーム
http://ja.dbpedia.org/sparql

<出演者名と出演映画の概要を取得するクエリの例>

select distinct ?starring_name ?abstract where {
      <http://ja.dbpedia.org/resource/映画作品一覧> <http://dbpedia.org/ontology/wikiPageWikiLink> ?movie .
      ?movie <http://dbpedia.org/ontology/abstract> ?abstract .
      ?movie <http://ja.dbpedia.org/property/出演者> ?starring .
      ?starring rdfs:label ?starring_name.
}

<実行結果イメージ>
スクリーンショット 2016-07-04 21.16.37.png

NLCのトレーニングデータはCSVですので、フォーマットにCSVを指定すれば、ほぼそのまま使えます。
今回のアプリの例で言うと、上記クエリ結果の映画の概要をtext、出演者名をclassとして使用する訳です。
(Watsonに解釈させるためには、Wikipediaの概要部分だけでは少し情報不足でしたが)

DBpediaの使い方についてのもう少し詳しい情報は、こちらの記事を参照すればよろしいかと。
Wikipedia からスクレイピングして… とか言ってる人におすすめしたい,DBPedia からの情報抽出
DBpediaのある主語の述語プロパティwikiPageWikiLinkの目的語となる文字列を取得するためのSPARQLクエリ

APIリクエストパラメータ

公式DocumentのAPI Reference(英語)を参照。

レスポンスサンプル(API Referenceからの転載)

Classify

{
  "classifier_id": "10D41B-nlc-1",
  "url": "https://gateway.watsonplatform.net/natural-language-classifier/api/v1/classifiers/10D41B-nlc-1/classify?text=How%20hot%20wil/10D41B-nlc-1",
  "text": "How hot will it be today?",
  "top_class": "temperature",
  "classes": [
    {
      "class_name": "temperature",
      "confidence": 0.9998201258549781
    },
    {
      "class_name": "conditions",
      "confidence": 0.00017987414502176904
    }
  ]
}

iOS

ライブラリ管理

Carthageを利用しました。
こちらの記事が参考になります。
Carthage導入

チャットUI(JSQMessagesViewController)

以下の記事を参考にさせていただきました。(ほぼコピペ)
Swiftで超簡単にチャットアプリのUIを作る

プロジェクト作成時、テンプレートに"Single View Application"を選択し、ViewControllerのBaseClassにJSQMessagesViewControllerを指定するだけです。(下記コード参照)
Storyboardはデフォルトのままです。

通信制御およびJSONパース

AlamofireとSwiftyJSONを使用しました。
こちらの記事がわかりやすいと思います。
AlamofireとSwiftyJSONでAPIを叩くチュートリアル

プログレスアニメーション

SVProgressHUDを利用しました。
こちらの記事が参考になります。
[iOS] 超簡単に処理中のUIを出せるSVProgressHUDについて

App Transport Security (ATS) 問題

どうやらWatson APIはiOS 9以降のATSの要件をクリアしていないようで、APIリクエストでエラーが発生しました。
ATSの概要、エラーおよび回避方法は、以下の記事を参照です。
[iOS,Swift] App Transport Security(ATS)を解除する方法

アイコン素材

こちらの素材を利用させていただきました。
ICOOON MONO

コード

あくまで自己学習用なのでエラーハンドリングは甘いです。
アーキテクチャデザインもあえて簡素にしています。

ViewController.swift
import UIKit
import JSQMessages
import Alamofire
import SwiftyJSON
import SVProgressHUD

class ViewController: JSQMessagesViewController {

    // MARK: Declaration

    var messages: [JSQMessage]?
    var incomingBubble: JSQMessagesBubbleImage!
    var outgoingBubble: JSQMessagesBubbleImage!
    var incomingAvatar: JSQMessagesAvatarImage!
    var outgoingAvatar: JSQMessagesAvatarImage!

    // APIリクエスト時に必要となるユニークなDialogID
    let DIALOG_ID = "(実際の値を設定)"
    // WatsonDialogサービスのサービス資格情報
    let DIALOG_USER = "(実際の値を設定)"
    let DIALOG_PASS = "(実際の値を設定)"
    var conversationId: String = ""
    var clientId: String = ""

    // Natural Language Classifier(NLC)APIリクエスト時に必要となるユニークなClassifierId
    let CLASSIFIER_ID_TITLE = "(実際の値を設定)"
    let CLASSIFIER_ID_STARRING = "(実際の値を設定)"
    // WatsonNLCサービスのサービス資格情報
    let NLC_USER = "(実際の値を設定)"
    let NLC_PASS = "(実際の値を設定)"

    // WatsonのResponse文字列の中で、名前を表示させる固定文字列(この文字列を置換する)
    let REPLACE_STRING = "%NAME%"
    // WatsonDialogの変数名
    let DIALOG_VAR_TITLE = "TITLE"
    let DIALOG_VAR_STARRING = "STARRING"

    // MARK: Event

    override func viewDidLoad() {
        super.viewDidLoad()

        // 自分のsenderId, senderDisokayNameを設定
        self.senderId = "user1"
        self.senderDisplayName = "hoge"

        // 吹き出しの設定
        let bubbleFactory = JSQMessagesBubbleImageFactory()
        self.incomingBubble = bubbleFactory.incomingMessagesBubbleImageWithColor(UIColor.jsq_messageBubbleBlueColor())
        self.outgoingBubble = bubbleFactory.outgoingMessagesBubbleImageWithColor(UIColor.jsq_messageBubbleGreenColor())

        // アバターの設定
        self.incomingAvatar = JSQMessagesAvatarImageFactory.avatarImageWithImage(UIImage(named: "watson.png")!, diameter: 64)
        self.outgoingAvatar = JSQMessagesAvatarImageFactory.avatarImageWithImage(UIImage(named: "you.png")!, diameter: 64)

        // メッセージデータの配列を初期化
        self.messages = []

        receiveAutoMessage()
    }

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
        // Dispose of any resources that can be recreated.
    }

    // MARK: Override Method

    /// Sendボタンが押された時に呼ばれる
    override func didPressSendButton(button: UIButton!, withMessageText text: String!, senderId: String!, senderDisplayName: String!, date: NSDate!) {

        // 新しいメッセージデータを追加する
        let message = JSQMessage(senderId: senderId, displayName: senderDisplayName, text: text)
        self.messages?.append(message)

        // メッセジの送信処理を完了する(画面上にメッセージが表示される)
        self.finishReceivingMessageAnimated(true)

        // メッセージを受信
        self.receiveAutoMessage()

    }

    /// アイテムごとに参照するメッセージデータを返す
    override func collectionView(collectionView: JSQMessagesCollectionView!, messageDataForItemAtIndexPath indexPath: NSIndexPath!) -> JSQMessageData! {
        return self.messages?[indexPath.item]
    }

    /// アイテムごとのMessageBubble(背景)を返す
    override func collectionView(collectionView: JSQMessagesCollectionView!, messageBubbleImageDataForItemAtIndexPath indexPath: NSIndexPath!) -> JSQMessageBubbleImageDataSource! {
        let message = self.messages?[indexPath.item]
        if message?.senderId == self.senderId {
            return self.outgoingBubble
        }
        return self.incomingBubble
    }

    /// アイテムごとにアバター画像を返す
    override func collectionView(collectionView: JSQMessagesCollectionView!, avatarImageDataForItemAtIndexPath indexPath: NSIndexPath!) -> JSQMessageAvatarImageDataSource! {
        let message = self.messages?[indexPath.item]
        if message?.senderId == self.senderId {
            return self.outgoingAvatar
        }
        return self.incomingAvatar
    }

    /// アイテムの総数を返す
    override func collectionView(collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return (self.messages?.count)!
    }

    // MARK: Private Method

    /// 返信メッセージを受信する
    func receiveAutoMessage() {
        SVProgressHUD.show()
        if self.conversationId == "" {
            postDialog("")
        } else {
            let inputText = self.inputToolbar.contentView.textView.text
            self.inputToolbar.contentView.textView.text = ""
            postDialog(inputText)
        }
    }

    /// Watson Dialogと対話を行う
    /// - parameter input: 入力テキスト
    func postDialog(input: String) {
        // conversation_idとは複数クライアントから同じDialogにアクセスしても独立した対話になるように割り振られるID
        // conversation_idは初回のAPIアクセス時に返却され、2回目以降の対話ではconversation_idが必須となる
        let params = [
            "conversation_id": self.conversationId,
            "client_id": self.clientId,
            "input": input,
            ]
        Alamofire.request(.POST, "https://gateway.watsonplatform.net/dialog/api/v1/dialogs/\(self.DIALOG_ID)/conversation", parameters: params)
            .authenticate(user: self.DIALOG_USER, password: self.DIALOG_PASS)
            .responseJSON { response in
                if response.result.error == nil {
                    guard let object = response.result.value else {
                        return
                    }
                    let json = JSON(object)
                    print("\n--Debug postDialog json:")
                    print(json)
                    if self.conversationId == "" {
                        self.conversationId = json["conversation_id"].stringValue
                        self.clientId = json["client_id"].stringValue
                    }
                    let messageText = self.editResponse(json["response"].arrayValue)
                    if messageText.containsString(self.REPLACE_STRING) {
                        // 固定文字列が含まれていたらDialogの変数より絞り込み条件を取得しNLCからclassを取得し文字列置換する
                        self.getDialogVariables(messageText)
                    } else {
                        self.dispResponse(messageText)
                    }
                }
        }
    }

    /// Response配列のメッセージを編集する
    /// - parameter responseArray: WatsonのResponse要素(配列)
    /// - returns: 編集後の文字列
    func editResponse(responseArray: Array<SwiftyJSON.JSON>) -> String {
        var messageText: String = ""
        for text in responseArray {
            if messageText != "" {
                messageText += "\n"
            }
            messageText += text.string!
        }
        return messageText
    }

    /// Watson Dialogの変数(=絞り込み条件)を取得する
    /// - parameter messageText: 表示文字列
    func getDialogVariables(messageText: String) {
        Alamofire.request(.GET, "https://gateway.watsonplatform.net/dialog/api/v1/dialogs/\(self.DIALOG_ID)/profile?client_id=\(self.clientId)")
            .authenticate(user: self.DIALOG_USER, password: self.DIALOG_PASS)
            .responseJSON { response in
                print("\n--Debug getDialogVariables response:")
                print(response.result.value)
                if response.result.error == nil {
                    guard let object = response.result.value else {
                        return
                    }
                    let json = JSON(object)
                    print("\n--Debug getDialogVariables json:")
                    print(json)
                    // Dialogの変数名と値を配列化
                    let variables = json["name_values"].arrayValue
                    var classifierId = ""
                    var filterText = ""
                    for varText in variables {
                        guard let varName = varText["name"].string else {
                            return
                        }
                        if varName == self.DIALOG_VAR_TITLE {
                            // 作品名指定の場合
                            classifierId = self.CLASSIFIER_ID_TITLE
                            if varText["value"].stringValue != "" {
                                filterText = varText["value"].stringValue
                                break
                            }
                        }
                        if varName == self.DIALOG_VAR_STARRING {
                            // 出演者名指定の場合
                            classifierId = self.CLASSIFIER_ID_STARRING
                            if varText["value"].stringValue != "" {
                                filterText = varText["value"].stringValue
                                break
                            }
                        }
                    }
                    self.getClassFromNLC(classifierId, messageText: messageText, filterText: filterText)
                }
        }
    }

    /// NLCのResponseを取得する
    /// - parameter classifierId: ClassifierID
    /// - parameter messageText: 表示文字列
    /// - parameter filterText: フィルタ文字列
    func getClassFromNLC(classifierId: String, messageText: String, filterText: String) {
        let params = [
            "text": filterText,
            ]
        Alamofire.request(.GET, "https://gateway.watsonplatform.net/natural-language-classifier/api/v1/classifiers/\(classifierId)/classify", parameters: params)
            .authenticate(user: self.NLC_USER, password: self.NLC_PASS)
            .responseJSON { response in
                print("\n--Debug getClassFromNLC response:")
                print(response.result.value)
                if response.result.error == nil {
                    guard let object = response.result.value else {
                        return
                    }
                    let json = JSON(object)
                    print("\n--Debug getClassFromNLC json:")
                    print(json)
                    let classJson = json["classes"].arrayValue
                    var dispText = ""
                    if classJson.count > 0 {
                        for i in 0...classJson.count - 1 {
                            // classes配列は確信度の降順に並んでいる(らしい)のでそのまま使う
                            if i > 2 {
                                // 3件超は長くなりすぎるので
                                break
                            }
                            if dispText != "" {
                                dispText += "または"
                            }
                            dispText += ("「" + classJson[i]["class_name"].stringValue + "」")
                        }
                    }
                    let s = messageText.stringByReplacingOccurrencesOfString(self.REPLACE_STRING, withString: dispText)
                    self.dispResponse(s)
                }
        }
    }

    /// Responseをチャットに表示する
    /// - parameter responseText: 表示文字列
    func dispResponse (responseText: String) {
        let message = JSQMessage(senderId: "user2", displayName: "underscore", text: responseText)
        self.messages?.append(message)
        self.finishReceivingMessageAnimated(true)
        SVProgressHUD.dismiss()
    }   
}

Githubリポジトリ

https://github.com/y-some/DialogTest-movie

まとめ

  • 今年日本語化対応が行われたWatsonですが、まだ日本語の解析精度は低いと感じます。今後の精度向上に期待します。
  • Dialogの日本語情報が相変わらず少なくて苦戦しました。こちらも公式ドキュメントの日本語化が待たれます。
  • 『映画のタイトル、または、出演者を答えてくれる』というのは、題材としてはイマイチかも知れません。(アイデア不足 :sweat_smile: )NLCの用途というのは自然言語の意図を汲み取ることであって、データベースのような使い方ではないのかも。
  • iOS部分の実装は各種ライブラリのおかげでカンタンでした♪