LoginSignup
15
12

More than 5 years have passed since last update.

Googleアシスタントとしりとりしてみる

Posted at

二児のパパエンジニアです。

Google Homeのアプリ作って家族とエンジョイみたいな記事が最近多いので、そこに乗っかってみよう(+スキルアップ)な感じで嫁と相談してみました。

自分:「どういうの作ると子供って喜ぶと思う?」
嫁:「しりとり。最近やってる」

おおぅ、確かにそうだけど、、、しりとりって地味に難しくない?
まあ、まずはやってみようと、ちょっとやってみましたの記事です。

ちなみにまだGoogle Homeは持ってないのでGoogleアシスタントでのテストまでになります。
買ってみてどうなるかは、また後日。

構成

  • Googleアシスタント
  • Dialogflow
  • Firebase
  • Actions On Google

GoogleアシスタントのアプリなのでDialogflowはほぼ必須として。(そこは自前むずいですよね)
それだけでしりとりの処理は無理やろと、バックエンドの処理はActions On Googleで。
あとサーバーサイドは参考記事も豊富でディプロイしやすそうなFirebaseを使うことにしました。

処理の流れ

  1. アプリを呼び出す
  2. ユーザー名を決める
  3. Googleアシスタントからしりとりをはじめる
  4. ユーザーがしりとりを返す
  5. (繰り返し)
  6. 「ン」がついたら終了
  7. 勝敗を宣言して、もう一回やるか確認

今回のところは、Googleアシスタントからの開始で固定しました。
じゃんけんするわけにもいかないし(別にやってもいいんだけど趣旨ぶれる)、会話のとっかかりは音声インターフェースの難しい部分ですよね。

あと、実は今回の処理には、しりとりの辞書っていうとってもめんどうなものが必要なのですが、そこは別で記事起こしたいと思います。
とりあえず概要だけ言うと、適当なものが思う浮かばなかったのでWikipediaのタイトルからピックアップした辞書をPythonで適当に作成しました。

プロジェクトの準備

まずは、プロジェクトを作ります。

  1. Firebaseコンソールプロジェクトを追加で、適当に追加
  2. Actions On GoogleコンソールAdd/import projectで作成したプロジェクトを選択

Dialogflow

Dialogflowの使い方は公式読むのが一番わかりやすいと思います。

Dialogflowは、人間の会話そのものをプログラムレスでインターフェースに変換してくれます。
ややこしい処理を考えなければ、Dialogflow単体でアプリが完成してしまうぐらい強力です。

以下の部分を実装してみます。

  1. アプリを呼び出す
  2. ユーザー名を決める

Dialogflowの準備

Actions On Googleコンソールの、ActionsからDialogflowを選択すると、Dialogflowコンソールに飛びます。

dialogflow.png

console.png

Dialogflowで必要な設定は以下になります。
1. Intents
2. Fulfillment

Intentsの作成

Intentsは必要な会話の定義や設定、曖昧な会話の処理を決定します。
今回必要な会話は以下としました。

  • Welcome(アプリを呼び出す)
  • YourName(ユーザー名を決める)
  • Shirotori(しりとり)
  • Again(再開)
  • Fallback(無意味な会話)
  • End(終了)

WelcomeとEndとFallbackは省略します。
他の記事を参考にしてください。

YourNameとShiritoriを作ってみます。
まずはYourNameから。

Dialogflowコンソールの左メニューからIntents+を選択し、追加画面に移動します。

まずはアプリ呼び出しのきっかけになる言葉を設定します。
Training phrasesに入力します。
(ここ最初戸惑ったのですが、どうもUser Saysと言われていたものがTraining phrasesと変わってようです。注意です)

yourname2.png

名前を決めるので、こんな感じでしょうか。〇〇の部分はそれぞれなので曖昧な表現にしておきます。
色が変わってますが、これは後で説明します。

次にAction and parametersを設定します。

yourname4.png

このアクション名とパラメータはActions On Googleで受け取れるデータを定義します。
ENTITYは型のようなもので、会話から切り出した部分を指定の形式として処理します。
今回はぶっちゃけなんでもいいので@sys.anyにしてます。(よくわかってないですが、Any型みたいなもんでしょう)
VALUEは会話の切り出し部分の位置の定義です。

ここでTraining phrasesに戻ります。
さっきの〇〇の部分をダブルクリックすると上記で設定した定義が参照できます。

yourname3.png

設定すると色が変わります。
これで、実際には「私は太郎です」と言おうが「私は花子です」と言おうが、DialogflowはこのIntentsを呼び出してくれて、〇〇にあたる部分をパラメータとして抜き出してくれます。

次に、この会話をきっかけに次の会話へつなげていきたいので、Contextsを設定します。

yourname.png

Contextsを設定することで、会話に連続性が生まれます。
output contextに設定したデータは次以降の会話に引き渡されるので、それを判定して会話の分岐判断を行えます。

すでにWelcomeのIntentsでoutput intentsを設定してあります。
ですので、今回の言葉をいきなり喋ってもアプリは反応しないようにすることができます。

最後にこのIntentsをActions On Googleで処理できるように、FulfillmentをONにします。

yourname5.png

FulfillmentはこのIntentsの処理をwebhookと繋げてくれる設定です。
実際に繋げるURLは別で設定します。

Shiritoriもほぼ同じ内容になります。

ContextsではYourNameで設定したものを引き渡したいので、そのまま繋げます。

shiritori.png

Action and parametersもしりとりの単語を受け取るだけなので、シンプルに設定します。

shiritori2.png

Training phrasesは少し注意で、しりとりなので話した全体が候補となるので、適当な単語を入力して全体をパラメータにしてしまいます。

shiritori3.png

あとはFulfillmentをONにして終了です。

Fulfillmentの設定

webhookとして処理するURLを設定します。
左メニューのFulfillmentを選択し、WebhookENABLEDにし、URLを入力します。

fulfillment.png

が、まだFirebase側を作成してないのでURLも何もないので、ここは一旦終了です。

Firebase

次は、Webhook側の処理を書いていきます。
別に何のサーバー、何の言語でも使ってもいいんですが、環境が整っているFirebaseを使うことにします。
無料でほとんど制限なく豊富な機能も、DBも使えます。素晴らしいです。

一点、他のサービスを使う場合、Google以外のドメインにアクセスするとエラーとなるらしいので、そこだけ注意してください。

使用する機能は以下となります。

  • Firebase Functions
  • Firebase Realtime Database

Firebase Functionsの準備

Firebase Functionsは、今のところnode.jsでのみ開発できます。

Firebase Functionsのデプロイは非常に簡単で、npmをインストールしてCLI経由でアップロードできます。

まずは以下でインストール。

$ npm install -g firebase-tools

成功したらfirebaseコマンドが使えるようになります。
ログインします。

$ firebase login

Webページでログインしていいか?と聞いてくるので、許可しましょう。WebページにリダイレクトしてGoogleアカウントのログインをします。

成功したら、環境の準備です。
適用にフォルダ作って、初期化します。

$ firebase init functions

紐づけるプロジェクトを聞かれるので、今回作成したものにしましょう。
あと、TypeScriptかJavaScriptかも聞かれます。自分の環境に適したものを選びましょう。

全部終わったらfunctionsフォルダができます。
そこが開発環境となるので、そこに移動して開発をはじめます。

しりとり処理の開発

必要なファイルはpackege.jsonindex.jsです。
紆余曲折色々あったのですが、最終的に以下のようなコードになりました。
npmは必要なものをインストールしましょう。

index.js
// モジュール
process.env.DEBUG = 'actions-on-google:*';
const { DialogflowApp } = require('actions-on-google');
const functions = require('firebase-functions');
const admin = require('firebase-admin');
const Promise = require('promise');
const kuromoji = require('kuromoji');

// Firebase Realtime Database
admin.initializeApp(functions.config().firebase);
const db = admin.database();

// DBの取得
function getDB (name) {
  return new Promise(function (resolve, reject) {
    db.ref('/' + name).once('value', function (snapshot) {
      resolve(snapshot.val());
    });
  });
}

// DBの更新
function setDB (name, value) {
  db.ref('/' + name).set(value);
}

// 全DBデータの取得
function getAllDB () {
  return Promise.all([getDB('dict'), getDB('player_results'), getDB('now_player'), getDB('used_word')]);
}

// 読み仮名取得
function yomigana (word) {
  return new Promise(function (resolve, reject) {
    const builder = kuromoji.builder({dicPath: 'node_modules/kuromoji/dict/'});
    builder.build(function (err, tokenizer) {
      if (err != null) {
        reject(err);
        return;
      }
      let yomi = '';
      let path = tokenizer.tokenize(word);
      path.forEach(function (v, i, a) {
        yomi += v['reading'] ? v['reading'] : '';
      });
      resolve(yomi || word);
    });
  });
}

// 単語をランダムで取得
function wordRandom (initialKey, shiritoriDict) {
  const wordDict = shiritoriDict[initialKey];
  const wordLength = Object.keys(wordDict).length;
  const wordKey = Object.keys(wordDict)[Math.floor(Math.random() * wordLength)];
  return wordDict[wordKey];
}

// 最初の単語をランダムで取得
function firstWordRandom (shiritoriDict) {
  const initalKeyLength = Object.keys(shiritoriDict).length;
  const initialKey = Object.keys(shiritoriDict)[Math.floor(Math.random() * initalKeyLength)];

  let word = '';
  do {
    word = wordRandom(initialKey, shiritoriDict);
  } while (word['good'] === false);

  return word;
}

// 次の単語をランダムで取得
function nextWordRandom (last, usedWordList, shiritoriDict) {
  let newWord = {};
  let result = null;

  do {
    // 次の文字
    do {
      newWord = wordRandom(last, shiritoriDict);
    } while (usedWordList.indexOf(newWord['word']) !== -1);

    // 使用済み
    usedWordList.push(newWord['yomigana']);
    setDB('used_word', usedWordList);

    // 最後の文字判定
    const newLast = newWord['yomigana'].slice(-1);
    result = validLastWord(newLast);
  } while (result === 'no_word' || result === 'no_kana');

  return newWord;
}

// 最初の文字判定
function validPlayerWord (word, usedWordList) {
  // 異常
  if (!usedWordList) {
    return 'no_word';
  }
  // 一文字はだめ
  if (usedWordList.length === 0) {
    return 'one_word';
  }
  // 前回と違う言葉は除外
  if (usedWordList[usedWordList.length - 1].slice(-1) !== word) {
    return 'mismatch_word';
  }

  // 成功
  return null;
}

// 最後の文字判定
function validLastWord (word) {
  // 空は終わり
  if (!word) {
    console.log('isLoseWord: no word, ' + word);
    return 'no_word';
  }

  // ンは終わり
  if (word === '') {
    console.log('isLoseWord: end word, ' + word);
    return 'lose_word';
  }

  // 許可する仮名
  const shiritoriKana = [
    '', '', '', '', '',
    '', '', '', '', '',
    '', '', '', '', '',
    '', '', '', '', '',
    '', '', '', '', '',
    '', '', '', '', '',
    '', '', '', '', '',
    '', '', '', '', '',
    '', '', '', '', '',
    '', '', '', '', '',
    '', '', '', '', '',
    '', '', '', '', '',
    '', '', '',
    '', '', '', '', '',
    '', ''
  ];

  // カタカナ以外は終わり
  if (shiritoriKana.indexOf(word) === -1) {
    console.log('isLoseWord: no kana word, ' + word);
    return 'no_kana';
  }

  // 成功
  return null;
}

// 戦果記録
function writeResult (win, nowPlayer, playerResults) {
  console.log('result: ' + nowPlayer['name'] + '' + (win ? '勝ち' : '負け'));

  let result = playerResults[nowPlayer['name']];
  if (!result) {
    result = {'win': 0, 'lose': 0, 'started_at': nowPlayer['started_at'], 'last_played_at': nowPlayer['started_at']};
  }

  result['win'] += win ? 1 : 0;
  result['lose'] += win ? 0 : 1;
  result['last_played_at'] = nowPlayer['started_at'];

  playerResults[nowPlayer['name']] = result;
  setDB('player_results', playerResults);
}

// メイン処理(Actins)
exports.mainAction = functions.https.onRequest((request, response) => {
  // Dialogflowのシーケンス
  const app = new DialogflowApp({request, response});
  console.log('Request headers: ' + JSON.stringify(request.headers));
  console.log('Request body: ' + JSON.stringify(request.body));

  // 開始
  function yournameResponseHandler (app) {
    getAllDB()
    .then(function (data) {
      const shiritoriDict = data[0];
      const playerResults = data[1];

      // 相手の名前
      const player = request.body.result.parameters.yourname;
      console.log(player);

      // プレイヤーの決定
      setDB('now_player', {name: player, started_at: (new Date()).getTime()});

      let message = '';

      // 過去の戦績
      const result = playerResults[player];
      if (result) {
        console.log(player + 'の成績は' + result['win'] + '' + result['lose'] + '');
        message += player + 'の成績は' + result['win'] + '' + result['lose'] + '敗でしたね。';
      } else {
        console.log(player + 'とは初めて');
        message += player + 'とは初めて会いましたね。';
      }

      // 最初のしりとり
      console.log('shiritori start!');
      message += 'さあ、しりとりをはじめましょう。私から行きますよ。';

      // 最初の言葉を取得
      const word = firstWordRandom(shiritoriDict);

      console.log('first word: tori-peは「' + word['word'] + '(' + word['yomigana'] + ')」と言った');
      message += '最初の言葉は、' + word['yomigana'] + 'です。' + word['yomigana'].slice(-1) + 'でお願いします。';

      // 使用済み
      setDB('used_word', [word['yomigana']]);

      // 返事する
      app.ask(message);
    });
  }

  // しりとり
  function shiritoriResponseHandler (app) {
    getAllDB()
      .then(function (data) {
        const shiritoriDict = data[0];
        const playerResults = data[1];
        const nowPlayer = data[2];
        const usedWordList = data[3];

        // 相手の言葉
        const word = request.body.result.parameters.yourword;
        let message = '';

        // 読み仮名
        yomigana(word)
          .then(function (yomi) {
            message = yomi + 'ですね。';

            // 最初の文字判定
            const first = yomi.slice(0, 1);
            console.log('first: ' + first);
            let result = validPlayerWord(first, usedWordList);
            if (result !== null) {
              switch (result) {
                case 'no_word':
                  message += '変ですね。消えませんでした。もう一度お願いします。';
                  break;
                case 'one_word':
                  message += '一文字は禁止です。もう一度お願いします。';
                  break;
                case 'mismatch_word':
                  message += 'はじめの言葉が違いますよ。もう一度お願いします。';
                  break;
              }
              // 返事する
              app.ask(message);
              return Promise.reject(new Error('error_first'));
            }

            console.log('next word: ' + 'プレイヤー' + 'は「' + word + '(' + yomi + ')」と言った');

            // 使用済み
            usedWordList.push(yomi);
            setDB('used_word', usedWordList);

            // 最後の文字判定
            let last = yomi.slice(-1);
            result = validLastWord(last);
            if (result !== null) {
              // 正常系以外
              switch (result) {
                case 'no_word':
                  message += 'その言葉は知りません。もう一度お願いします。';
                  break;

                case 'no_kana':
                  message += 'その言葉は知りません。もう一度お願いします。';
                  break;

                case 'lose_word':
                  message += 'ンがつきましたよ!あなたの負けです!';

                  writeResult(false, nowPlayer, playerResults);
                  const result = playerResults[nowPlayer['name']];
                  if (result) {
                    console.log(nowPlayer['name'] + 'の成績は' + result['win'] + '' + result['lose'] + '');
                    message += nowPlayer['name'] + 'の成績は' + result['win'] + '' + result['lose'] + '敗でしたね。';
                  }

                  console.log('shiritori end!');
                  message += 'もう一度チャレンジしますか?';
                  break;
              }

              // 返事する
              app.ask(message);
              return Promise.reject(new Error('error_last'));
            }

            message += '私の番です。';

            // 次の単語を取得
            let newWord = nextWordRandom(last, usedWordList, shiritoriDict);
            message += newWord['yomigana'] + 'です。' + newWord['yomigana'].slice(-1) + 'でお願いします。';

            // 最終文字判定
            last = newWord['yomigana'].slice(-1);
            result = validLastWord(last);
            if (result != null) {
              // 正常系以外
              message += 'あ!ンがついてしまいました!あなたの勝ちです!';

              writeResult(true, nowPlayer, playerResults);
              const result = playerResults[nowPlayer['name']];
              if (result) {
                console.log(nowPlayer['name'] + 'の成績は' + result['win'] + '' + result['lose'] + '');
                message += nowPlayer['name'] + 'の成績は' + result['win'] + '' + result['lose'] + '敗でしたね。';
              }

              console.log('shiritori end!');
              message += 'もう一度チャレンジしますか?';

              // 返事する
              app.ask(message);
              return Promise.reject(new Error('error_last_own'));
            }

            // 返事する
            app.ask(message);
          })
          .catch(function (error) {
            console.log(error);
          });
      });
  }

  // もう一回
  function againResponseHandler (app) {
    getAllDB()
    .then(function (data) {
      const shiritoriDict = data[0];
      const nowPlayer = data[2];

      // 相手の名前
      console.log(nowPlayer);

      let message = '';

      // 最初のしりとり
      console.log('shiritori start!');
      message += 'わかりました、もう一度やりましょう。私から行きますよ。';

      // 最初の言葉を取得
      const word = firstWordRandom(shiritoriDict);

      console.log('first word: tori-peは「' + word['word'] + '(' + word['yomigana'] + ')」と言った');
      message += '最初の言葉は、' + word['yomigana'] + 'です。' + word['yomigana'].slice(-1) + 'でお願いします。';

      // 使用済み
      setDB('used_word', [word['yomigana']]);

      // 返事する
      app.ask(message);
    });
  }

  // 受け答えに対応
  const actionMap = new Map();
  actionMap.set('action.yourname', yournameResponseHandler);
  actionMap.set('action.shiritori', shiritoriResponseHandler);
  actionMap.set('action.again', againResponseHandler);
  app.handleRequest(actionMap);
});

基本的には以下の流れです。
1. Dialogflowで定義したaction毎に処理をする
2. 受け取ったパラメータを処理、加工する
3. Dialogflowを使って返答する

Dialogflowのaction

以下の部分です。

const actionMap = new Map();
actionMap.set('action.yourname', yournameResponseHandler);
actionMap.set('action.shiritori', shiritoriResponseHandler);
actionMap.set('action.again', againResponseHandler);
app.handleRequest(actionMap);

定義した名称をキーとして、ハンドラーを登録します。
これだけで勝手に呼び出されます。

Dialogflowのパラメータ

Dialogflowから受け取った言葉は、
request.body.result.parameters.yourname
のようにパラメータとして受け取れます。

問題は受け取った言葉がそのままだと良い感じに日本語化されている(つまり漢字とかになってる)ので、読み仮名がわからんということです。
頭が良すぎるのも考えものですよね。読み仮名だけ欲しいってのに。

しょうがないので、kuromojiを使うことにしました。
あまりnode.jsに明るくない身なので、無邪気にmecabとか普通に使えると思い込んでて大分ハマりました。
ここら辺から、この方の記事に助けられて、色々参考にしています。

Dialogflowで返答

公式を参考にしてください。
普通に返答するのはaskです。

最初Googleが用意してくれているActions on Googleのサンプルコードのここを参考に書き始めたのですが、ここではtellが使われてました。
これが罠で、tellだと会話を終わらせてしまいます。
使ったらアプリが終了してしまいます。わけわからずに大分悩みました。
続けて会話を処理する場合はaskを使いましょう。

Firebase Realtime Database

しりとりの辞書とか、処理中の連続的なデータの格納としては、Firebase Realtime Databaseを使いました。
概要としては、辞書形式のデータ構造がそのまま読み書きできる、程度に把握しておけばいいでしょう。
ほとんどローカルストレージ的な扱いができるので、そこは非常に便利です。

データはコンソールから簡単に追加、削除できます。
Database - データJsonをインポートで追加、各項目のxボタンで削除できます。

ここで事前に用意しておいたしりとりの辞書をおもむろにインポートします。

データ構造としては以下のような感じで作ってます。

database.png

goodは見切れてますがtrueが入ってます。ンで終わるかどうかをセットしてます。
なんか色々構想あったんですが特にまだ使えていません。
yomiganaはローカルで解釈済みの読み仮名です。

Functionsでのデータのアクセス

node.jsでRealtime Databaseのデータにアクセスするのは非常に簡単ですが、注意が必要です。

追加・変更

Realtime Databaseのデータはパスで表現するので、ルートパスからの階層でアクセスします。
now_playerというデータがルート直下にある場合は以下のように書くことで値の変更を直接表せます。
まだ値がない場合は勝手に追加されます。

db.ref('/now_player').set(`hoge`);

取得

取得も同じです。

db.ref('/now_player').once('value', function (snapshot) {
    console.log(snapshot.val())
});

直接取得するのではなく、snapshot.val()なとこに気をつける以外は楽に書けます。
が、非同期で処理される部分は注意です。

これはPromise使ってasync awaitやな。と途中までコード書いたのですが、いざデプロイで罠が発覚。

Firebase Functionsのnode.jsのバージョンがv6.11.5なのです。

Node 7.6、async/awaitをデフォルトでサポート

ということで、使えないのです。
(node.jsシロートが気持ち良く書いたのに、、、)

最新のnodeコーダーのみなさんは注意してください。私は全部書き直しました。

Firebase Functionsのデプロイ

準備ができたのでデプロイします。以下を実行します。

$ firebase deploy --only functions

デプロイの前のに自動的にeslintでチェックが走ります。問題あればエラーになるので直して再実行しましょう。

必要であれば、.eslintrc.jsonを修正して良いのですが、上記既出のようにnode.jsのバージョンが古いのでそこは気をつけましょう。
自分のコードが悪くてエラーが出てるんじゃくて、バージョンが古いのでエラーが出てる場合もありますよ。

問題なければ、Deploy complete!になって終了します。

Firebase FunctionsのURL

コンソールのFunctions - ダッシュボードにあります。

url.png

そのURLをDialogflowのFulfillmentに戻って設定してください。

以上で、基本的な部分は終了です。お疲れ様でした。

Googleアシスタントのテスト

Dialogflowに戻ります。
左のメニューのIntegrationsをクリックし、Google Assistantをクリックします。

integr.png

表示されたダイアログでTESTをクリックします。

test.png

Actions On Googleのページに遷移します。

simu.png

ここはシミュレータのページです。
真ん中のモバイルっぽい表示の下に入力ボックスがあります。ここに入力してテストします。
今作ったアプリをテストしたい場合はテスト用アプリにつないでと入力すれば、Welcomeインテントが呼び出された状態からはじまります。

あとは、会話を進行してテストしてください。
何か問題があるとすぐテストアプリが退出しましたとなるので、都度Firebase Functionsのログ画面で状況を確認してデバッグしましょう。

また、この状態まで行くと実際のGoogleアシスタントでも呼び出せるようになります。
Googleアシスタントで、「テスト用アプリにつないで」と話しかけてください。

以上で全てです。

実際の動画

以下が実際のテスト動画です。

テストビデオ

アプリのタイトルは「とりっぺ」にしました。
「しりとり」「ウィッキディア」みたいなのです。
適当です。

やってみた結果としては、反応が思いの外遅いです。
デカい辞書を毎回読み直してるのとかあるのでしょうが、普通にDialogflowががんばってるんでしょうか。
あと認識率がめちゃくちゃ悪いのはGoogleアシスタントでしょうね。
受け答えも意味不明になるので、改良の余地ありです。

地味に、しりとりやろうとしても全然単語が出てこなくて衝撃でした。
完全に頭悪くなってるわ、自分。
最後のイランとかもはやギャグでしかない。

今後やること

  • 細かい受け答えの調整
  • Google Homeを買って子供とやらせてみる
  • 辞書を改良する

本当に子供が使ってくれるようになるか微妙ですが、もうちょっと考えます。
以上です。

15
12
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
15
12