追記(2016.09.10)
[Watson] Dialog Serviceの廃止について
http://qiita.com/y_some/items/de4cdea1b60d36243c0d
概要
曖昧な問いかけに対して、映画のタイトル、または、出演者を答えてくれるチャットボットを作ってみました。
長くなりますが、複数の技術要素を寄せ集めた成果ですので、あえて記事を分割せず一気に解説します。
技術要素と開発環境
Watson
iOS
- Xcode7.3
- Swift2.2
- Carthage
- JSQMessagesViewController
- Alamofire
- SwiftyJSON
- SVProgressHUD
その他
シーケンス図
サーバーサイド機能を実装して、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.
}
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
コード
あくまで自己学習用なのでエラーハンドリングは甘いです。
アーキテクチャデザインもあえて簡素にしています。
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リポジトリ
まとめ
- 今年日本語化対応が行われたWatsonですが、まだ日本語の解析精度は低いと感じます。今後の精度向上に期待します。
- Dialogの日本語情報が相変わらず少なくて苦戦しました。こちらも公式ドキュメントの日本語化が待たれます。
- 『映画のタイトル、または、出演者を答えてくれる』というのは、題材としてはイマイチかも知れません。(アイデア不足 )NLCの用途というのは自然言語の意図を汲み取ることであって、データベースのような使い方ではないのかも。
- iOS部分の実装は各種ライブラリのおかげでカンタンでした♪