BookBot:読書のためのチャットボット

BookBot: Chatbot for reading Book


2020/12/31
藤田昭人


大変ご無沙汰してしまいました。
4ヶ月間も沈黙して何をしていたのか?
…というとこれを作ってました。

bookbot.glitch.me

多くの人の予想に反して pure JavaScript のウェブ・アプリケーションです。 本稿ではこのシステム、BookBot の概要を説明します。


BookBot:読書のためのチャットボット

BookBotはその名のとおり「読書のためのチャットボット」でして、 利用者が「書籍について雑談をすることができるチャットボット」を目指しています。 このアイデア、親しい友人とチャットボットについて話していたときに 「書籍自身が書いてあることをかいつまんで話してくれたら、読書する手間が省ける」 と多読家らしい彼が語った一言が「対話コーパスとして書籍原文を使う」キッカケになりました。

実際、対話システムの開発に機械学習の技術を活用しようとなると対話コーパスが問題になります。 質問文と回答文のペアを学習データとして用意しなければならない対話コーパス機械学習を使った対話システムがそれなりに会話が成立するようになるには、 学習データとして10万程度の質問文&回答文ペアを集めなければならないとか。 そんな長大な対話録はあまり見かけません。

しかし、チャットボットを開発するために 新たに人手で対話コーパスを用意する場合には、 そこまで大量の対話録は必要ないようです。 ローブナー賞の覇者である リチャード・ウォレスへのニューヨーク・タイムズのインタビューよれば、 Alicebot の初期の開発では、まずはオフィス内でボットを試験運用を始めて、 (オフィス内の)利用者からの答えられなかった質問には その都度ウォレスが回答文を埋めていったそうです。

もしアリスが質問で混乱しているのを見ればそのたびに、 彼がアリスに新しい答えを教えたとしたら、 彼は最終的には一般的な発言すべてをカバーするだけなく、 さらには多くの変わった発言もカバーするだろう。 ウォレスは、マジックナンバーは約4万件の応答だと考えている。 これだけプログラムされた発言ができるようになれば、 アリスに向かって(あるいは「彼女は」と呼びはじめたときには) 人々が言っていることの95%に反応できるようになる。

チャットボットの人格を想定し、 それに相応しい回答文を用意すれば 機械学習の技術を使わなくても チャットボットは相応の応答ができるようになるそうです。つまり 「人間の会話をマスターできなかったのは、人間が複雑すぎるからではなく、単純すぎるからだ」 という。これはリチャード・ウォレスが発見した見識でしょう。

とはいえ「手作業で4万件の質問&応答の入力する」というのはねぇ…

…とモヤモヤしていたところへ飛び込んできたのが 友人の一言の「書籍を擬人化する」アイデアだったのです。

そもそも書籍(ブログもそうですが)とは著者自身の主張でもあります。 もしチャットボットの応答を書籍の中から探せば、 そのキャラクターは著者自身のキャラクターとなります。 そして、著者がまとまった量の文章を書いておれば、 新たに応答文を書き起こさなくても 任意の質問にもっとも相応しい1文を見つけ出すこともできるのでは?

結局、彼の一言が「対話コーパスとして書籍原文を使う」という思いつきに結び付くことになりました。 一言で説明すれば「情報検索の技術を応用して擬似的な対話コーパスを作る」というアイデアです。


システムの概要

既に出来上がっているシステムを紹介します。

まずプラットホームはGlitchを使っています。 基本的にはnode.jsexpress.jsJavaScriptによる 典型的なウェブ・アプリケーションです。 Glitchが用意してくれるhello-expresshello-sqliteを ベースに作成しましたので、基本的な構造はそのまま踏襲しています。 それからチャットボット風のUIが欲しかったのでBotUIも使っています*1。おかげで、チャットボットらしい見た目になりました。

一般的なチャットボットとは異なり、 BookBotは最初は書籍ブラウザーとして 起動します。起動画面はこんな感じ。


f:id:Akito_Fujita:20201228103437p:plain
BookBot

BotUIのおかげで、 スマホのウェブ・ブラウザーで表示すると スマホ・アプリが動いているように見えます。 それからBotUIのボタン機能を使って 次ページや前ページ、前後の見出しに移動できます。


現在収録しているブログ記事

残念ながら、今のところまだプロトタイプなので 僕が過去に書いたブログ記事しか閲覧できません。 現時点で収録してるのは下記の7本です。

タイトル
AI(人工知能)っていつから始まったの? ー ダートマス会議の内幕
人工無脳って知ってる?
ELIZA(1)「傾聴」を模倣するプログラムとは?
ELIZA(2)クライアント中心療法とは?
ELIZA(3)スクリプト ー 応答を作り出す仕掛け
ELIZA(4)DOCTOR スクリプト
ELIZA(5)開発の背景は?

僕のブログ記事は30本ほどあるので、 随時追加していく予定です。 また、任意のページでチャットボットを呼び出す 機能を実装する予定です。(詳細は後述)


Markdownドキュメントからのスクリプト生成

BookBotスクリプトの設計にはかなり悶絶し、結果的長い時間を使ってしまいました。 結論としては元になる記事の章立て、 つまり「文節(セクション)の連鎖」に基づくフラットな構造を採用しました。 その最大の理由は、まず「元となる記事から機械的な変換が可能」であるということなのですけども、 それだけではなくワイゼンバウム第2論文 から大きな影響を受けています*2

今のところ、記事はMarkdownで記述されていることを仮定していて、 MarkdownドキュメントからBookBotスクリプトを自動生成するプログラムを書きました。 Markdown の解釈には、以前紹介した 'Remark' を使っています。解釈に使っているモジュール parse-markdown.js のソースを末尾に添付しておきます。

モジュール parseMarkdown はMarkdownドキュメントを Markdown Abstract Syntax Tree(MDAST) という構文木に展開してくれますが、ここで留意すべきプラグインが2つあります。

1つは 'remark-gfm' 。remark のMarkdownパーサーは CommonMark Spec に基づくMarkdownドキュメントを解釈してくれますが、 このプラグインを使うとGithubで使われているMarkdown、すなわち GitHub Flavored Markdown Spec まで解釈してくれます。

もうひとつは 'remark-footnotes' で、これはMarkdownでの次の3種類の フットノートの記法に対応するプラグインです。

Here is a footnote reference,[^1]
another,[^longnote],
and optionally there are inline
notes.^[you can type them inline, which may be easier, since you don’t
have to pick an identifier and move down to type the note.]

[^1]: Here is the footnote.

[^longnote]: Here’s one with multiple blocks.

    Subsequent paragraphs are indented to show that they
belong to the previous footnote.

        { some.code }

    The whole paragraph can be indented, or just the first
    line.  In this way, multi-paragraph footnotes work like
    multi-paragraph list items.

This paragraph won’t be part of the note, because it
isn’t indented.

まぁ、はてなのマークダウンの記法とはちょっと違うので、 そこは困りもんなんですけども。


用語集を作るために

ちなみに、BookBotからの応答の下線部分はリンクになっています。 例えば、次のように表示されている時に…

f:id:Akito_Fujita:20201228125712p:plain
クリック前

下線付きの「スマートスピーカー」をクリックすると…

f:id:Akito_Fujita:20201228125744p:plain
クリック後

…と「スマートスピーカー」の説明が表示されます。

これ、実はMarkdownLink Reference Definition (日本語では「間接リンク」というのかな?) を活用してます。

Example 161

[foo]: /url "title"

[foo]

…というのが書式なんですけども、 BookBotでは次のように「タイトル」を「用語解説」に使ってます。

[スマートスピーカー]: https://ja.wikipedia.org/wiki/%E3%82%B9%E3%83%9E%E3%83%BC%E3%83%88%E3%82%B9%E3%83%94%E3%83%BC%E3%82%AB%E3%83%BC "用語: スマートスピーカー(英: Smart Speaker)とは、対話型の音声操作に対応したAIアシスタント機能を持つスピーカー。内蔵されているマイクで音声を認識し、情報の検索や連携家電の操作を行う。日本ではAIスピーカーとも呼ばれる。"

この記法の URL は Wikipedia へのリンク、 「用語解説」には Wikipedia ページの冒頭の文言を納めています。

ちなみに「用語解説」の先頭にある「用語」というのは、 固有表現タグを意識したカテゴリです*3。 このリンク定義はMarkdownをHTMLやPDFに変換すると表示されず、 その用語にはリンクが貼られます。 例えばMarkdownで次のように記述すると…

突如、[人工無脳][]に執着し始めた理由は[スマートスピーカー][]の登場でした。

普通にブラウザーで開くと次のように表示されます。

突如、人工無脳に執着し始めた理由はスマートスピーカーの登場でした。

で「スマートスピーカー」をクリックすると該当するWikipediaページに移動しますが、 BookBot の場合はタイトルに書かれている「用語説明」を表示する訳です。

この Link Reference Definition にはもう1つ使い道があって、 例えばMarkdownで次のように記述すると…

[Alexa][スマートスピーカー]のアプリ([スキル][]って言うのかな?)の開発者の世代では「対話」と言うとチャットボットのイメージが立ち上がるのでしょうか?

ブラウザでは…

Alexaのアプリ(スキルって言うのかな?)の開発者の世代では「対話」と言うとチャットボットのイメージが立ち上がるのでしょうか?

…と「Alexa」としか表示されませんが、 クリックするとWikipediaの「スマートスピーカー」のページに移動します。 実はBookBotではこの記法を同義語の定義に使っています。 このようにBookBotは、 ちょっとしたトリックを使って用語集を作ってます。


これからの作業

肝心の対話機能がないのでBookBotの開発はこれからが佳境です。

いわゆるAIチャットボットと呼ばれるサービスは 既にたくさん存在していますが、 その核技術として頻繁に登場するのがword2vecです。 その仕組みについては次のようなドキュメントが幾つも見つかりますが…

qiita.com

そこで示される実装例はPythonだけで JavaScriptの実装例は皆無といった状況です。 JS版のword2vecというと下記のパッケージがありますが

www.npmjs.com

これはオリジナルの Google の word2vec にJS向けのラッパーを付けただけなので困りもんです。 また日本語文章にword2vecを適用する場合には、 固有の問題がある そうなので、これまた悩みどころです。

一方、対話機能の実装という観点では、最近良い教科書が出版されました。

www.ohmsha.co.jp

これまたPythonなんですけども、ソースをチラチラ見てると 「JavaScriptに書き直すのはそれほど難しくないのでは?」などと考えています。 (そういえば、昔JavaのコードをC++に書き直しながら 両方の言語を覚えるという無茶をしたなぁ…)

というわけで…

しばらくはBookBotネタでブログを書くことにします。

以上

parse-markdown.js

'use strict'

var vfile = require('to-vfile');
var report = require('vfile-reporter');
var unified = require('unified');
var parse = require('remark-parse');
var gfm = require('remark-gfm');
var footnotes = require('remark-footnotes');


// rmposition -- ポジションデータを削除する

function rmposition() {
  return transformer;

  function transformer(tree, file) {
    traverse(tree);
  }

  function traverse(tree) {
    if (tree.position) delete tree.position;
    if (!tree.children) return
    for (var i = 0; i < tree.children.length; i++) {
      traverse(tree.children[i]);
    }
  }
}


// pluginCompile -- 終端処理

function pluginCompile() {
  this.Compiler = compiler;

  function compiler(tree) {
    //console.log(tree);
    return(tree);
  }
}


function parseMarkdown(name) {
  var processor = unified()
      .use(parse)
      .use(gfm)
      .use(footnotes, {inlineNotes: true})
      .use(rmposition)
      .use(pluginCompile);

  //console.log("# %s", name);
  processor.process(vfile.readSync(name), done);

  var result;
  function done(err, file) {
    //console.error(report(err || file));
    //console.log(file.result);
    result = file.result;
  }

  return(result);
}

module.exports = parseMarkdown;

*1:この二つを手名付けるのに苦労しました(笑)
実は、いろいろ初めての経験が重なったため 開発は予定より2ヶ月遅れてしまいました。
BotUI の使い方、特に JavaScriptの async/await の使い方はそのうち書きます。

*2:ワイゼンバウム第2論文 で、会話の構造について「カクテルパーティでのおしゃべり」と 「二人の物理学者の議論」の比較で考察しています。

例えば、カクテルパーティーのおしゃべりは直線的な傾向があります。 コンテキストは絶えず変更されています(ノードにはかなりの連鎖があります)が、 すでに確立された構造に沿った方向の逆転はほとんどありません。 会話は、何も言われても、より高いレベルで提起された質問に影響を与えないという点で重要ではありません。 これを、たとえば、ある実験の結果を理解しようとする2人の物理学者間の議論と比較してみましょう。 彼らの会話ツリーは、深いだけでなく広いものになります。 つまり、そこから新しいノードを生成するために、以前のコンテキストレベルに上昇します。 会話が正常に終了したというシグナルは、元のノードに戻った(元に戻った)こと、 つまり、(最初に)話し始めた内容について再び話していることです。

この考察に触発されて「読書という会話」について何度となく考えることに、 多くの時間を費やしてしまいました。例えば、読書を「新たな知識を得る活動」と捉えると、 知識のレベルの異なる2者間、「先生と生徒の会話」に置き換えることできます (典型的な事例はいわゆる「禅問答」という会話ですね)。 この場合、会話は先生側の知識を前提に会話が進むことになります。 「カクテルパーティでのおしゃべり」とは違い無目的ではないですが、 生徒の知識レベルが考慮されるので 「2人の物理学者の議論」ほど広さや深さは自ずと抑制されます。

また読書の場合、レベルの高い方の話者の知識は静的である (その場で捻り出されるセリフではない) ことから、その行為を会話と呼ぶなら 他よりも発散する度合いは小さいことも特徴になります。 書籍の著者は読者の理解を促すために熟慮の上で章立てを決めていると思うのですが、 読者の知識レベルが著者が想定しているほどフラットではないので (これは知っているがこれは知らないといった) 目次どおりに読み進められるとは限らない、 いわゆる(ウェブ・ページではよく議論される)読者の導線も考慮しなければなりません。

こういった問題に無理なく対応できるスクリプトの構造を考え始めた結果、 ベストの解は見出せなくなってしまいました。 結局、多くの人の読書の導線、つまり書籍の章立てを原則とし、 その時に読んでいる文節(セクション)が理解できなければ、 それについて書いてある書籍の他の文節(セクション)にジャンプするのだろうと 仮定することにしました。

応答文生成に関しても、 ELIZAのようなルールベース、 機械学習による機械的な生成、 さらにオートマトンによる手順的な応答など 複合的な戦略を、 各文節(セクション)毎に設定できる必要を感じました。 これもワイゼンバウムが提案する 「スクリプトの階層化」の延長上にあるアイデアです。

この、スクリプト界隈の実装は仕様がまだまだ揺れそうなので、 当面コードを積極的に公開する事はなさそうです。 どうあってもみたい人は Glitch のエディターで ソースを 'AS-IS' で見てください。

*3:ここで「固有表現タグ」にニヤッと反応した方はわかってらっしゃる(笑)
そうでない方が、これあたりを読んでいただける良いかと思います。

heat02zero.hatenablog.com

自然言語処理の世界では IREX というのがあるんだそうで、 論文 も出てました。要は次のように固有名刺を分類するのだそうです。

略号 説明 用例 IPADIC
ART 固有物名 ノーベル文学賞Windows7 41
LOC 地名 アメリカ、千葉県 46, 47
ORG 組織 自民党NHK 45
PSN 人名 安倍晋三メルケル 42, 43, 44
DAT 日付 1月29日、2016/01/29
TIM 時間 午後三時、10:30
MNY 金額 241円、8ドル
PNT 割合 10%、3割

ちなみにIPADICでも、一部の固有名詞には ID が振られています。

固有表現とは「意味的な最小単位」で、 MeCab などで出力される「形態素」よりは少し大きい というのが僕の理解です(正確な定義はよくわからんが)。

例えば、普通の日本人なら「ウィーン」と言えば地名、 「アインシュタイン」と言えば人物名と直感的に認識しますよね? もしチャットボットに同じことをさせるためには、 単語の属性として固有表現タグを貼っておかなければならない訳です。