Slackの会話を元に一句詠む

SlackでBotに一句詠ませるために、r7kamura/ikkuという一句抽出ライブラリと、これを利用したr7kamura/ruboty-kokodeikkuというRuboty用プラグインをつくりました。

様子

このようにチャット上にBotを置いておくと、会話に反応して一句詠んでくれます。

image

ruboty-kokodeikku

ruboty-kokodeikkuは、チャットBot用のフレームワークであるRubotyのためのプラグインです。Botが参加している部屋の全ての発言を監視し、一句として妥当なパターンがあれば一句詠んでくれます。

image

使い方

使い方は、mecabを使えるようにしてruboty-kokodeikkuをGemfileに追加するとOKです。Herokuで利用する場合は、以下のように環境変数を設定すると良いでしょう。

heroku config:set \
  BUILDPACK_URL=https://github.com/diasks2/heroku-buildpack-mecab.git\
  LD_LIBRARY_PATH=/app/vendor/mecab/lib\
  MECAB_PATH=/app/vendor/mecab/lib/libmecab.so

Heroku buildpack: linuxbrew - Qiitaを参考にheroku-buildpack-multiを利用して上手くMeCabを入れたかったのですが、上手くいかなかったので今回はheroku-buildpack-mecabを利用してMeCabを使えるようにしました。Ruboty自体の導入についてはRuby製HubotクローンのRubotyをSlackで動かす - Qiitaを読むと良いでしょう。

実装

Rubotyでは、Ruboty::Handlers::Baseを継承するクラスをつくると発言に反応できるようになり、on メソッドでパターンを登録できます。今回は全ての発言に反応して一句が詠めるかどうか調べたかったので、// を指定して全ての文字列に一致する正規表現を与え、更に all: true を指定してBOTにmentionを送らなくても反応するようにしました。

Rubotyは全てのプラグインをGemfileで管理するため、プラグインをつくるにはGemをつくる必要があります。最初は、簡単に試すためGistにGemを置いていました。詳しくはGistでGemを公開する - Qiitaに書いています。

require "ikku"
 
module Ruboty
  module Handlers
    class Kokodeikku < Base
      on(
        //,
        all: true,
        description: "ここで一句",
        name: "kokodeikku",
      )
 
      def kokodeikku(message)
        if !message.body.start_with?("ここで一句 ") && (phrases = reviewer.find(message.body))
          message.reply("ここで一句 #{phrases.map(&:join).join(' ')}")
        end
      end
 
      private
 
      def reviewer
        @reviewer ||= Ikku::Reviewer.new
      end
    end
  end
end

Ikkuの導入方法

r7kamura/ikkuを使うための準備について説明します。

MeCab

Ikkuでは内部で[MeCab](MeCab - Wikipedia)というオープンソースの形態素解析エンジンを利用しています。そのため、MeCabとその辞書データが利用できる環境になっている必要があります。MacでHomebrewを利用している場合には、以下のようにインストールできます。

brew install mecab mecab-ipadic

Ruby

IkkuはRubyを利用して記述しました。コード内でキーワード引数を利用したために、Ruby 2.0.0以上を必要とします。MeCabのRuby用ライブラリであるNattoを利用しています。MeCab RubyではなくNattoを利用したのは、インストールの難易度の低さを優先したためです。

Ikkuの使い方

r7kamura/ikkuのライブラリの使い方を説明します。

Ikku::Reviewer.new(rule: nil)

Ikkuの主なインターフェースは Ikku::Reviewer というクラスです。文字列から一句を抽出するなどの目的で利用する場合は、このクラスのインスタンスを生成し、メソッドを呼んで文字列を与えることになります。デフォルトでは五七五のパターンを一句として認識しますが、rule: [4, 3, 5] のように引数を与えることでこれを変更することもできます。

reviewer = Ikku::Reviewer.new
reviewer = Ikku::Reviewer.new(rule: [5, 7, 5, 7])

Ikku::Reviewer#judge(text)

与えられた文字列が一句かどうかを判定し、trueまたはfalseを返すメソッドです。与えられた文字列中に一句が含まれているかどうかではなく、与えられた文字列全てを消費して一句詠めているかどうかを判定します。

reviewer.judge("古池や蛙飛び込む水の音") #=> true
reviewer.judge("ああ古池や蛙飛び込む水の音ああ") #=> false

Ikku::Reviewer#find(text)

与えられた文字列の中から一句を探し、最初に見つかったものを返します。見つからなかった場合はnilを返します。メソッド名をfindにしたのは Enumerable#find に倣ってのことです。ちなみにIkku::Reviewerは一句批評家です。

ここで返却される一句を表すオブジェクトは、複雑ですが、Ikku::Nodeのインスタンスの配列の配列です。例えば以下の例では、"古池"と"や"が五七五の最初の五を表す部分であり、二つのIkku::Nodeから構成されることを示しています。Ikku::Nodeにはインスタンスメソッドとしてto_sが定義されているので、一句を表現するオブジェクトに join メソッドを呼ぶとひと繫ぎの文字列が得られます。

reviewer.find("ああ古池や蛙飛び込む水の音ああ")
#=> [["古池", "や"], ["蛙", "飛び込む"], ["水", "の", "音"]]

Ikku::Reviewer#search(text)

与えられた文字列の中に含まれる全ての一句を探し、それらの配列を返却します。findメソッドでは一つ見つかった瞬間に結果を返していましたが、このメソッドでは処理を継続して全ての一句の可能性を探索します。

reviewer.search("ああ古池や蛙飛び込む水の音ああ天秤や京江戸かけて千代の春ああ")
#=> [
#     [["古池", "や"], ["蛙", "飛び込む"], ["水", "の", "音"]],
#     [["天秤", "や"], ["京", "江戸", "かけ", "て"], ["千代", "の", "春"]]
#   ]

Ikkuの仕組み

Ikkuの内部動作について説明します。

Ikku::Reviewer

一句レビュアーです。前に説明しました。Ikku::Parserを使って与えられた文字列をIkku::Nodeの配列に変換し、Ikku::Scannerを使って一句を探索します。

Ikku::Parser

一句パーサーです。与えられた文字列を形態素解析エンジンにかけ、Ikku::Nodeの配列に変換します。MeCabで文字列を解析すると各形態素を表すNodeの配列が得られますが、Ikku::NodeはそれらのNodeをそれぞれ内包したものです。 言語パーサで言うところの字句解析器のような働きをします。コメント部分を表す字句が廃棄される言語処理系があるように、Ikku::Parserではこの段階で不要なノードを廃棄します。例えば、文頭や文末のようなメタ情報を表すノードは廃棄しています。

Ikku::Scanner

一句スキャナーです。与えられたIkku::Nodeの配列を先頭から消費していって、一句が成り立つかどうかを判定します。言語パーサで言うところの構文解析器のような働きをします。

必ず先頭から消費できなければ失敗で、例えば「ああ古池や蛙飛び込む水の音ああ」のような文字列が与えられた場合に、このScannerでの判定は失敗します。「古池や蛙飛び込む水の音ああ」を表すIkku::Nodeの配列が与えられなければいけないわけです。一句を抽出する際、Ikku::Reviewerでは、Parserから得られたIkku::Nodeの配列をもとに様々な組み合わせをつくり、それぞれScannerに与えて判定させます。例えばIkku::Reviewer#findに「ああ古池や蛙飛び込む水の音ああ」が渡されたとき、Reviewerは以下のパターンをつくってそれぞれScannerに判定させます。

ああ古池や蛙飛び込む水の音ああ
  古池や蛙飛び込む水の音ああ <= これが一致する
    や蛙飛び込む水の音ああ
     蛙飛び込む水の音ああ
      飛び込む水の音ああ
          水の音ああ
           の音ああ
            音ああ
             ああ

findでは一つ見つけた場合に処理を終えますが、searchではその後も処理を継続し、全てのパターンを探索します。find, searchでは「古池や蛙飛び込む水の音ああ」を表す配列が与えられた場合でも、五七五が取り出せた段階で成功ということになりますが、judgeでは完全に一句かどうか知りたいという要求があります。そのため、Scanner生成時に exactly: true というオプションが用意されており、judgeではこのオプションが利用されます。

Ikku::Node

一句ノードです。個々の形態素を表しています。MeCabから得られた品詞などの情報を簡単に扱えるように処理をラップしている他、モーラのような文節単位で音数を計算する処理なども担当しています。例えば古池は四音節、河童は三音節ということになり、これを五七五の計算に利用します。

また、自身が一句の先頭や末尾のノードとしてふさわしいかや、五・七・五の各句の先頭のノードとしてふさわしいかどうか、という情報を返す役割も担っています。例えば、接尾辞(-さ、-っぽい、-的など)を各フレーズの先頭に配置するのはふさわしくない、などです。

これらの細かなルールづくりの違いが、句を句として認識する際の差異に繋がります。この辺の個人によって趣向のことなる事柄については処理を分離し、Ikkuはあくまで静的に解析できる情報を提供するに留め、利用者が外部からルールベースを与えられるようにできると良いですね。

おわり

r7kamura/ikkuという一句抽出ライブラリと、それを利用したr7kamura/ruboty-kokodeikkuというRuboty用プラグインについて紹介しました。一晩でガッと書いたので雑な仕組みになってるとは思いますが、まあまあ動くし便利という感じにはなったかと思います。完璧な一句というものが存在しないように、完璧な判定器というものもまたアレなわけですので、各位Ikkuを参考に君だけの最強の判定器をつくっていきましょう。

あわせて詠みたい

https://twitter.com/kokodeikku_bot