BluemixのWatson APIを駆使して日本語質問応答システムを作る～その2～

# はじめに
[こちら](http://qiita.com/VegaSato/items/5f98831457f22b5ffced)ではNLCとRRを繋げて質問応答システムを作った。そして、[こちら](http://qiita.com/VegaSato/items/0b7ab85dde97a9d1f4a9)では日本語テキストをしゃべらせてみた。ということは、**これらを組み合わせれば、回答をしゃべってくれる質問応答システムができるじゃないか！**ということで、早速つくってみた。

# Bluemixで動かしてみた
まずはイメージを持ってもらえれば、ということで、これから説明するコードをBluemixで動かしたのがこちら。
[http://satohdai-watsondemo.mybluemix.net/](http://satohdai-watsondemo.mybluemix.net/)

そして処理のイメージ図はこちら。本当は入力もSpeech to Text(S2T)を使って実現したかったのだけど今回は割愛。入力のところだけS2Tにすればいい、はず。

![Picture1.png](https://qiita-image-store.s3.amazonaws.com/0/102519/817eae48-d526-af92-1bfc-72aab469ce26.png)


# Webアプリ化の注意点
音を鳴らすにあたって、色々調べてみたけど結局ブラウザのAudio機能を使うのがいいみたいだ。ちょうど質問応答システムを、いつまでもnodeで直接動かすんじゃなくて、そろそろブラウザベースにしたいなーと思っていたところだったのでちょうどいい。

Webアプリにする場合、コードがサーバーサイドとクライアントサイドに分かれるので注意が必要だ。app.js(とrouteなど)に書いておくサーバーサイドコードと、クライアント側のhtml(とそこから読み込まれるjavascript)は、変数や関数が互いに見えない。

クライアント→サーバーはhttpリクエストで関数を呼び出し、サーバー→クライアントはレスポンスとして値を返す必要がある。

こんなの当たり前なのかもしれないが、メインフレームでCOBOLerだった私はこれに気づくのに半年かかってしまった。

# クライアント⇔サーバー間のインターフェース
サーバー側では、Natural Language Classifier(NLC)、Retrieve&Rank(RR)、Text to Speech(T2S)それぞれにRESTでアクセスできるようにし、それぞれのURL、レスポンスの型、ステータスの意味は以下のとおりとした。

|機能|URL|レスポンス|status|
|:---:|:---|:---|:---|
|NLC|/natural_language_classifier?q=質問|{ cl: "クラス", message: "メッセージ", confidence: "確信度" }|200=成功、404=該当クラスなし(確信度低)、500=重大エラー|
|RR|/retrieve_and_rank?cl=クラス&q=質問|{ answer: "回答", message: "メッセージ" }|200=成功、404=回答なし、500=重大エラー|
|T2S|/text_to_speech?text=しゃべる内容|オーディオストリーム(あってる？)|なし|

# 実際のコード
前置きが長くなったので手っ取り早くコードを紹介しておく。ディレクトリー構造は以下のような感じにした。javascriptをindex.htmlの中に書いていたら結構長くなってきたのでengagementservice0.jsとして外出しした。

```
/
+--- public/ 
|    +--- js/
|    |    +--- engagementservice0.js
|    +--- stylesheets/
|    |    +--- style.css
|    +--- index.html
+--- app.js
+--- package.json
```

## サーバーサイド app.js
サーバーサイドは以下のような感じにした。結構長くなってしまった。nodeベースのバッチで動かす質疑応答システムのエッセンスは全てこちらに詰まっている。

```js:app.js
/*eslint-env node*/
// -------------------------------------------------------------------------------
// Initialization
// -------------------------------------------------------------------------------
var express = require('express');
var cfenv = require('cfenv');
var watson = require('watson-developer-cloud');
var qs  = require('querystring');

var app = express();
app.use(express.static(__dirname + '/public'));
var appEnv = cfenv.getAppEnv();

app.listen(appEnv.port, '0.0.0.0', function() {
  console.log("server starting on " + appEnv.url);
});

// -------------------------------------------------------------------------------
// Watson API Credentials
// -------------------------------------------------------------------------------
// Speech to Text
var s2t = watson.speech_to_text({
  username: 'S2TのユーザーID',
  password: 'S2Tのパスワード',
  version: 'v1'
});

// Text to Speech
var t2s = watson.text_to_speech({
  username: 'T2SのユーザーID',
  password: 'T2Sのパスワード',
  version: 'v1'
});

// Retrieve&Rank
var rr = watson.retrieve_and_rank({
  username: 'RRのユーザーID',
  password: 'RRのパスワード',
  version: 'v1'
});

// Natural Language Classifier
var nlc = watson.natural_language_classifier({
  username: 'NLCのユーザーID',
  password: 'NLCのパスワード',
  version: 'v1'
});

// -------------------------------------------------------------------------------
// NLCによるクラス分け
// -------------------------------------------------------------------------------
app.get('/natural_language_classifier', function(req, res) {
  var q = req.query.q;
  console.log("[GET] /natural_language_classifierが呼ばれました");
  nlc.classify({
      text: q,
      classifier_id: '3AE103x13-nlc-918' 
    },  function(err, response) {
      if (err) {
        console.log('error:', err);
        return res.status(500).send('{ "message": "重大な重大なエラーが発生しました。管理者に問い合わせてください。"}');
      } else {
        if (response.classes[0].confidence < 0.95) {
        	console.log(">>> 認識されたクラス = " + response.classes[0].class_name + " / confidence = " + response.classes[0].confidence);
        	var response = { cl: response.classes[0].class_name,
        	                 message: 'すみません、質問がよくわかりません。',
        	                 confidence: response.classes[0].confidence };
        	return res.status(404).send(JSON.stringify(response));
        } else {
        	console.log(">>> 認識されたクラス = " + response.classes[0].class_name + " / confidence = " + response.classes[0].confidence);
        	var response = { cl: response.classes[0].class_name,
        	                 message: 'クラスが見つかりました。',
        	                 confidence: response.classes[0].confidence };
        	return res.status(200).send(JSON.stringify(response));
        }
      }
    }); 
});

// -------------------------------------------------------------------------------
// RRによる応答
// -------------------------------------------------------------------------------
app.get('/retrieve_and_rank', function(req, res) {
  console.log("[GET] /retrieve_and_rankが呼ばれました");
  var cl = req.query.cl;
  var q = req.query.q;
  var cid = "SolrクラスターID";
  var colname = cl+"-collection";
  var rid;
  
  // ランカーID
  if (cl == "iPhone") {
    rid = "iPhone用ランカーID";
  } else {
    rid = "Android用ランカーID";
  }

  // 設定確認
  console.log(">>> クラスターID   : " + cid);
  console.log(">>> コレクション名 : " + colname);
  console.log(">>> ランカーID     : " + rid);
  console.log(">>> 質問 : " + q);

  // パラメーター設定
  var params = {
    cluster_id: cid,
    collection_name: colname
  };
  
  var solrClient = rr.createSolrClient(params);

  var ranker_id = rid;
  var question  = 'q=' + q;
  var query     = qs.stringify({q: question, ranker_id: ranker_id, fl: 'id,title,body'});
  console.log("query = " + query);
  
  solrClient.get('fcselect', query, function(err, searchResponse) {
    if(err) {
      console.log('Error searching for documents: ' + err);
      return res.status(500).send('{ "message": "重大な重大なエラーが発生しました。管理者に問い合わせてください。"}');
    } else {
      if(searchResponse.response.numFound == 0) {
        // 回答が見つからない
        console.log("回答が見つかりませんでした");
        var response = { message: '回答が見つかりませんでした。' };
        return res.status(404).send(JSON.stringify(response));
      } else {
        // 1つ目の回答を返す
        console.log(">>> 回答 : " + searchResponse.response.docs[0].body);
        var response = { answer: searchResponse.response.docs[0].body,
                         message: '回答が見つかりました。' };
        return res.status(200).send(JSON.stringify(response));
      }
    }
  });
});

// -------------------------------------------------------------------------------
// Text to Speechでしゃべる
// -------------------------------------------------------------------------------
app.get('/text_to_speech', function(req, res) {
  var text = req.query.text;
  console.log("function speech : text = " + text);
  var params = {
    text: text,
    voice: 'ja-JP_EmiVoice',
    accept: 'audio/wav'
  };        
  var transcript = t2s.synthesize(params);
  transcript.on('response', function(response) {
    });
  transcript.pipe(res);
});
```

## クライアントサイド index.html
index.htmlは非常に簡単。

```html:index.html
<!DOCTYPE html>
<html>
  <head>
    <title>Engagement Service with Watson</title>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <link rel="stylesheet" href="stylesheets/style.css">
    <script type="text/javascript" src="js/engagementservice0.js"></script>
  </head>

  <body>
    <center>
      <h1>Engagement Service with Watson</h1>
      <h2>スマートフォンのことを聞いてください</h2>
        <form>
          <textarea id="q" cols="80" rows="3" placeholder="質問を入力してください" onInput="checkLength(this)"></textarea>
          <br>
          <input type="button" id="askButton" value="質問する" onClick="askQuestion()" disabled>
          <br>
          <br>
          <textarea id="answerArea" cols="80" rows="3" disabled>Watsonの回答</textarea>
        </form>
    </center>
  </body>
</html>
```

RESTサービス呼び出しとレスポンスのハンドリングなどはengagementservice0.jsの方に書いてある。とりあえず動かしたかったので処理フローは非常に単純だ。これでもそこそこそれっぽく見える。

```js:engagementservice0.js
// -------------------------------------------------------------------
// NLCによるクラス分け
// -------------------------------------------------------------------
var invokeNLC = function(q) {        
  var xhr = new XMLHttpRequest();
  xhr.onreadystatechange = function (){
    switch(xhr.readyState){
      case 4:
        if(xhr.status == 0){
          console.log("XHR 通信失敗");
        }else{
          if(xhr.status == 200){
            console.log("受信:" + xhr.responseText);
            invokeRR(JSON.parse(xhr.responseText).cl, q);
          }else{
            console.log("その他の応答:" + xhr.status);
            console.log("その他の応答:" + xhr.responseText);
            var answerArea = document.getElementById("answerArea");
            answerArea.innerHTML = JSON.parse(xhr.responseText).message;
            invokeT2S(JSON.parse(xhr.responseText).message);
          }
        }
      break;
    }
  };
  var url = "./natural_language_classifier?q=" + q;
  xhr.open("GET", url, true);
  xhr.send("");
}

// -------------------------------------------------------------------
// RRによる応答
// -------------------------------------------------------------------
var invokeRR = function(cl, q) {
  console.log("これからRRを呼ぶよ");
  var xhr = new XMLHttpRequest();
  xhr.onreadystatechange = function (){
    switch(xhr.readyState){
      case 4:
        if(xhr.status == 0){
          console.log("XHR 通信失敗");
        }else{
          if(xhr.status == 200){
            console.log("受信:" + xhr.responseText);
            var answerArea = document.getElementById("answerArea");
            answerArea.innerHTML = JSON.parse(xhr.responseText).answer;
            invokeT2S(JSON.parse(xhr.responseText).answer);
          }else{
            console.log("その他の応答:" + xhr.status);
            console.log("その他の応答:" + xhr.responseText);
            var answerArea = document.getElementById("answerArea");
            answerArea.innerHTML = JSON.parse(xhr.responseText).message;
            invokeT2S(JSON.parse(xhr.responseText).message);
          }
        }
      break;
    }
  };
  var url = "./retrieve_and_rank?cl=" + cl + "&q=" + q;
  xhr.open("GET", url, true);
  xhr.send("");
}

// -------------------------------------------------------------------
// T2Sによりしゃべる
// -------------------------------------------------------------------
var invokeT2S = function(text){
  var audio = document.createElement("audio");
  console.log("text = " + text);
  audio.src = "./text_to_speech?text=" + text;
  audio.play();      
}

// -------------------------------------------------------------------
// 質問の長さを確認してボタンを有効／無効化
// -------------------------------------------------------------------
var checkLength = function($this) {
  var askButton = document.getElementById("askButton");
  if ($this.value.length > 0) {
    askButton.disabled = false;
  } else {
    askButton.disabled = true;
  }
}

// -------------------------------------------------------------------
// NLCに入れる前準備
// -------------------------------------------------------------------
var askQuestion = function() {
  // 回答欄のクリア
  var answerArea = document.getElementById("answerArea");
  answerArea.innerHTML = "Watsonの回答";
  
  // 質問文のセット
  var q = document.getElementById("q").value;
  console.log("q = " + q );
  invokeNLC(q);
}

// -------------------------------------------------------------------
// XMLHttpRequest オブジェクトを作成する関数
// -------------------------------------------------------------------
function XMLHttpRequestCreate(){
  try{
    return new XMLHttpRequest();
  }catch(e){}
  // IE6
  try{
    return new ActiveXObject('MSXML2.XMLHTTP.6.0');
  }catch(e){}
  try{
    return new ActiveXObject('MSXML2.XMLHTTP.3.0');
  }catch(e){}
  try{
    return new ActiveXObject('MSXML2.XMLHTTP');
  }catch(e){}
  // not supported
  return null;
};
```

CSSはこちらを見てほしい。

```css:style.css
html {
	background-color: #3b4b54; width : 100%;
	height: 100%;
	margin: 0 auto;
	font-family: "HelveticaNeue-Light", "Helvetica Neue Light", "Helvetica Neue", Helvetica, Arial, "Lucida Grande", sans-serif;
	color: #ffffff;
	display: table;
}

body {
	background-color: #3b4b54; width : 100%;
	height: 100%;
	margin: 0 auto;
	font-family: "HelveticaNeue-Light", "Helvetica Neue Light", "Helvetica Neue", Helvetica, Arial, "Lucida Grande", sans-serif;
	color: #ffffff;
	display: table-cell;
	vertical-align: middle;
}

#content {
	padding: 20px;
}

a {
	text-decoration: none;
	color: #00aed1;
}

a:hover {
	text-decoration: underline;
}

.newappIcon {
	padding-top: 10%;
	display: block;
	margin: 0 auto;
	padding-bottom: 2em;
	max-width:200px;
}

h1 {
	font-weight: bold;
	font-size: 2em;
}

.leftHalf {
	float: left;
	background-color: #26343f;
	width: 45%;
	height: 100%;
}

.rightHalf {
	float: right;
	width: 55%;
	background-color: #313f4a;
	height: 100%;
	overflow:auto;
}

.blue {
	color: #00aed1;
}


table {
	table-layout: fixed;
	width: 800px;
	margin: 0 auto;
	word-wrap: break-word;
	padding-top:10%;
}

th {
	border-bottom: 1px solid #000;
}

th, td {
	text-align: left;
	padding: 2px 20px;
}

.env-var {
	text-align: right;
	border-right: 1px solid #000;
	width: 30%;
}

pre {
	padding: 0;
	margin: 0;
}

```
Node.js環境でBluemixを作ったら最初から入ってた。

最後にBluemix環境で動かすのであれば、package.jsonを修正しないといけない。Nodeランタイムのデフォルトのpackage.jsonだとモジュールの依存関係でexpressとcfenvしか書いてない。`watson-developer-cloud`と`qs`を追加しておこう。

```json:package.json
{
	"name": "NodejsStarterApp",
	"version": "0.0.1",
	"description": "A sample nodejs app for Bluemix",
	"scripts": {
		"start": "node app.js"
	},
	"dependencies": {
		"express": "4.12.x",
		"cfenv": "1.0.x",
		"watson-developer-cloud":"*",
		"qs":"*"
	},
	"repository": {},
	"engines": {
		"node": "0.12.x"
	} 
}
```

# Bluemixで動かしてみた
ということで最初に立ち返り、Bluemixで動かしてみたのだが、音声がちょっとレスポンス悪いかもしれない。それでも概ねちゃんと動いている。

# おわりに
ようやくWatsonにしゃべらせるところまでこぎつけた。あとはSpeech to Textを使って人間がしゃべった内容をテキスト化して入力してくれたりすると面白そうだ。機会があったらやってみたい。